初始化

This commit is contained in:
gozeros committed 2026-09-07 18:24:55 +08:00
1 parent 34474bd5fc
commit b638b541d0
3008 files changed
+837699

No files matched your search

@@ -0,0 +1,68 @@
# 报告协作工作台(AgentScope 多智能体)—— 前端模块
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
**联调对接:** `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`(环境配置、API 时序、验收清单)。
**进度(2026-09-06):** 页面与契约已完成,默认对接真实后端(`GET/POST /api/report-collaboration/*`)。后端 `RC-BE-000`~`018` 已落地。联调见 `docs/agentscope-多智能体报告协作工作台-前端对接指南.md`。
## 结构
```text
src/report-collaboration/
api/ # 契约类型 + REST/SSE 客户端
types.ts # 前后端共享领域类型(冻结契约,字段与后端 Pydantic 一致)
client.ts # rcFetch:鉴权、X-Idempotency-Key、X-Expected-Revision、409/422/429/5xx 错误映射
sessions.ts / plans.ts / runs.ts / reports.ts # 端点函数
driver.ts # 真实后端统一门面(getReportCollaborationDriver;Mock 驱动已移除)
state/
event-normalizer.ts # SSE/REST 事件防御性归一化
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
selectors.ts # 页面状态投影(并行聚焦过滤、画布方案、阶段推导)
message-adapter.ts # CollaborationMessage → LangGraph Message + BaseStream 外观 + 虚拟业务卡
hooks/
useReportCollaborationSession.ts # 快照→SSE→合帧 reducer→动作(send/select/startRun/commands)
useReportCollaborationStream.ts # SSE 连接/指数退避重连/after_seq 续接
useReportCollaborationMessages.ts # 消息组装(聚焦过滤 + 业务卡 + thread 外观)
useReportPanelLayout.ts # 两栏宽度(持久化)、窄屏切换、键盘分隔条
useReportPlanSelection.ts # 预览(纯前端)与选定(服务器事实)分离
components/ # Header / 两栏 Layout / 会话面板(复用 MessageList)/ 方案卡 /
# 画布(@xyflow/react)/ 节点抽屉 / Composer / 干预卡 / 报告预览 / 历史 / 状态栏
pages/ReportCollaborationPage.tsx
styles/report-collaboration.css # --rc-* 语义 token(Chrome 109 兼容,无 color-mix)
tests/ # node:test 单测(reducer / adapter),运行:pnpm test:report-collaboration
```
## 路由与入口
- `/page/workspace/report-collaboration` 新会话;`/page/workspace/report-collaboration/:sessionId` 会话页(按 sessionId 重挂)。
- 侧边栏导航项「报告协作」(`workspace-nav-chat-list.tsx`)。
- `WorkspaceRoutes.tsx` 引入页面与 CSS。
## 关键约束(与计划一致)
- 消息区**复用** `MessageList`:AgentScope 文本 / TeamSay / 工具调用与返回经
`message-adapter.ts` 一对一映射,经 `ThreadContext.Provider` + 合成 `BaseStream` 渲染;
不另做摘要卡或第二套渲染器。
- MessageList 新增的**通用业务卡插槽** `businessCardSlot`(分组 `assistant:business-card`,
判据 `additional_kwargs.report_collaboration_card`,见 `core/messages/utils.ts`)只在本页
传入;其他页面零行为变化。
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
## 只走真实后端
Mock 驱动(`mock.ts`)与切换开关(`deerflow.rc-mock` / `VITE_RC_MOCK`)已移除,
页面永远请求真实后端。历史遗留的浏览器设置可执行
`localStorage.removeItem('deerflow.rc-mock')` 清掉(不再被读取)。日常联调请保证:
- `config.yaml` → `report_collaboration.enabled: true`
- 需要成员自动执行时再开 `worker_enabled: true`
## 后端契约冻结说明
`api/types.ts` 按后端计划 §5/§7 编写,已与 `app/report_collaboration/contracts/` 对齐。
快照端点约定 `GET /sessions/{id}` 返回 `SessionSnapshot`
(session/messages/plans/run/nodes/agent_runs/commands/report_versions/last_seq)。
`GET /runs/{id}/stream` 已按 `after_seq` / `Last-Event-ID` 重放 durable 事件(`RC-BE-011`)。queued run 的领取/租约恢复已落地(`RC-BE-012`),质量角色内核已落地(`RC-BE-013`)。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
@@ -0,0 +1,142 @@
/**
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
*
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
* (保留输入,允许同一幂等键重试)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { ApiErrorDetail } from "./types";
export const REPORT_COLLABORATION_BASE = () =>
`${getBackendBaseURL()}/api/report-collaboration`;
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly detail?: unknown;
constructor(status: number, message: string, code?: string, detail?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.detail = detail;
}
}
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
export class ConflictError extends ApiError {}
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
export class ValidationError extends ApiError {}
/** 429:预算、并发或速率限制,不自动重试。 */
export class RateLimitError extends ApiError {}
/** 5xx:服务端错误,可带同一幂等键重试。 */
export class ServerError extends ApiError {}
/** 网络层错误(fetch reject)。 */
export class NetworkError extends ApiError {
constructor(message: string) {
super(0, message, "network");
}
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `rc-${crypto.randomUUID()}`;
}
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
try {
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
const source = "message" in data ? data : null;
if (source) return source;
if (typeof data.detail === "string") {
return { message: data.detail };
}
return null;
} catch {
return null;
}
}
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
if (res.status === 409) return new ConflictError(409, message, code, detail);
if (res.status === 422) return new ValidationError(422, message, code, detail);
if (res.status === 429) return new RateLimitError(429, message, code, detail);
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
return new ApiError(res.status, message, code, detail);
}
export interface RcRequestInit extends Omit<RequestInit, "body"> {
body?: unknown;
idempotencyKey?: string;
expectedRevision?: number | null;
/** 404 时返回 null 而不是抛错。 */
nullOn404?: boolean;
signal?: AbortSignal;
}
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
const headers = new Headers();
headers.set("Accept", "application/json");
if (init.body !== undefined) {
headers.set("Content-Type", "application/json");
}
if (init.idempotencyKey) {
headers.set("X-Idempotency-Key", init.idempotencyKey);
}
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
headers.set("X-Expected-Revision", String(init.expectedRevision));
}
let res: Response;
try {
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
credentials: "include",
signal: init.signal,
});
} catch (err) {
throw new NetworkError(err instanceof Error ? err.message : String(err));
}
if (res.status === 404 && init.nullOn404) {
return null;
}
if (!res.ok) {
const detail = await parseErrorDetail(res);
throw errorForStatus(
res,
detail?.message ?? `request failed (${res.status})`,
detail?.code,
detail?.detail,
);
}
if (res.status === 204) {
return null;
}
const text = await res.text();
if (!text) {
return null;
}
return JSON.parse(text) as T;
}
/** GET 便捷封装(非空断言版)。 */
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const data = await rcRequest<T>(path, { method: "GET", signal });
if (data === null) {
throw new ApiError(404, `resource not found: ${path}`);
}
return data;
}
@@ -0,0 +1,117 @@
/**
* 驱动接口:直接请求真实后端(不再提供 Mock 驱动)。
*/
import * as plansApi from "@/report-collaboration/api/plans";
import * as reportsApi from "@/report-collaboration/api/reports";
import * as runsApi from "@/report-collaboration/api/runs";
import * as sessionsApi from "@/report-collaboration/api/sessions";
import type {
CollaborationCommand,
CollaborationEvent,
CollaborationMessage,
CreateCommandPayload,
CreateRewritePayload,
NodeContractDetail,
ReportRewriteProposal,
ReportRun,
ReportSession,
ReportSessionSummary,
ReportVersion,
SendMessagePayload,
SessionSnapshot,
StartRunPayload,
} from "@/report-collaboration/api/types";
export interface OpenStreamHandlers {
onEvent: (event: CollaborationEvent) => void;
onOpen?: () => void;
onClose?: () => void;
onError?: (message: string) => void;
}
export interface ReportCollaborationDriver {
listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]>;
getSnapshot(sessionId: string, signal?: AbortSignal): Promise<SessionSnapshot | null>;
createSession(title?: string): Promise<ReportSession>;
updateSession(
sessionId: string,
patch: { title?: string },
expectedRevision?: number | null,
): Promise<ReportSession | null>;
deleteSession(sessionId: string): Promise<void>;
sendMessage(
sessionId: string,
payload: SendMessagePayload,
idempotencyKey: string,
): Promise<CollaborationMessage>;
requestPlans(sessionId: string, expectedRevision?: number | null): Promise<void>;
selectPlan(
sessionId: string,
planId: string,
expectedRevision?: number | null,
): Promise<void>;
startRun(
sessionId: string,
payload: StartRunPayload,
expectedRevision?: number | null,
): Promise<ReportRun>;
getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null>;
listRunEvents(runId: string, afterSeq: number, signal?: AbortSignal): Promise<CollaborationEvent[]>;
openRunStream(
runId: string,
afterSeq: number,
handlers: OpenStreamHandlers,
): () => void;
cancelRun(runId: string): Promise<ReportRun>;
createCommand(
runId: string,
payload: CreateCommandPayload,
idempotencyKey: string,
): Promise<CollaborationCommand>;
confirmCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
cancelCommand(runId: string, commandId: string): Promise<CollaborationCommand>;
getNodeContract(runId: string, nodeId: string, signal?: AbortSignal): Promise<NodeContractDetail>;
listReportVersions(sessionId: string, signal?: AbortSignal): Promise<ReportVersion[]>;
createRewrite(sessionId: string, payload: CreateRewritePayload): Promise<ReportRewriteProposal>;
applyRewrite(sessionId: string, rewriteId: string): Promise<ReportVersion>;
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
}
export const realDriver: ReportCollaborationDriver = {
listSessions: sessionsApi.listSessions,
getSnapshot: sessionsApi.getSessionSnapshot,
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
updateSession: sessionsApi.updateSession,
deleteSession: sessionsApi.deleteSession,
sendMessage: sessionsApi.sendMessage,
requestPlans: (sessionId, expectedRevision) =>
plansApi.requestPlans(sessionId, undefined, expectedRevision),
selectPlan: async (sessionId, planId, expectedRevision) => {
await plansApi.selectPlan(sessionId, planId, expectedRevision);
},
startRun: runsApi.startRun,
getRun: runsApi.getRun,
listRunEvents: runsApi.listRunEvents,
openRunStream: runsApi.openRunStream,
cancelRun: runsApi.cancelRun,
createCommand: runsApi.createCommand,
confirmCommand: runsApi.confirmCommand,
cancelCommand: runsApi.cancelCommand,
getNodeContract: runsApi.getNodeContract,
listReportVersions: reportsApi.listReportVersions,
createRewrite: reportsApi.createRewrite,
applyRewrite: reportsApi.applyRewrite,
restoreReportVersion: reportsApi.restoreReportVersion,
};
export function getReportCollaborationDriver(): ReportCollaborationDriver {
return realDriver;
}
@@ -0,0 +1,46 @@
/**
* 候选计划:plan request / 查询 / 选定(`/sessions/{id}/plans*`)。
*
* 选择方案与开始执行是两个独立命令:select 只写 `selected_plan_id`,
* 不创建 run。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type { ReportPlanCandidate } from "./types";
export async function requestPlans(
sessionId: string,
idempotencyKey = newIdempotencyKey(),
expectedRevision?: number | null,
): Promise<void> {
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}/plan-requests`, {
method: "POST",
body: {},
idempotencyKey,
expectedRevision,
});
}
export async function listPlans(
sessionId: string,
signal?: AbortSignal,
): Promise<ReportPlanCandidate[]> {
const data = await rcGet<{ plans: ReportPlanCandidate[] }>(
`/sessions/${encodeURIComponent(sessionId)}/plans`,
signal,
);
return data.plans ?? [];
}
/** 采用方案:409 表示方案 revision 过期或会话已选定,需刷新后重新确认。 */
export async function selectPlan(
sessionId: string,
planId: string,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<{ selected_plan_id: string }> {
return rcRequest<{ selected_plan_id: string }>(
`/sessions/${encodeURIComponent(sessionId)}/plans/${encodeURIComponent(planId)}/select`,
{ method: "POST", body: {}, idempotencyKey, expectedRevision },
).then((res) => res as { selected_plan_id: string });
}
@@ -0,0 +1,52 @@
/**
* 报告版本、章节改写候选与版本恢复(`/sessions/{id}/reports*`)。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type { CreateRewritePayload, ReportRewriteProposal, ReportVersion } from "./types";
export async function listReportVersions(
sessionId: string,
signal?: AbortSignal,
): Promise<ReportVersion[]> {
const data = await rcGet<{ versions: ReportVersion[] }>(
`/sessions/${encodeURIComponent(sessionId)}/reports`,
signal,
);
return data.versions ?? [];
}
/** 发起章节重写 / 润色:只生成 replacement candidate,不直接覆盖正式版本。 */
export async function createRewrite(
sessionId: string,
payload: CreateRewritePayload,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRewriteProposal> {
return rcRequest<ReportRewriteProposal>(
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites`,
{ method: "POST", body: payload, idempotencyKey },
).then((res) => res as ReportRewriteProposal);
}
export async function applyRewrite(
sessionId: string,
rewriteId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportVersion> {
return rcRequest<ReportVersion>(
`/sessions/${encodeURIComponent(sessionId)}/report-rewrites/${encodeURIComponent(rewriteId)}/apply`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as ReportVersion);
}
/** 版本恢复:创建新的头版本,不删除历史。 */
export async function restoreReportVersion(
sessionId: string,
versionId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportVersion> {
return rcRequest<ReportVersion>(
`/sessions/${encodeURIComponent(sessionId)}/report-versions/${encodeURIComponent(versionId)}/restore`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as ReportVersion);
}
@@ -0,0 +1,205 @@
/**
* Run 执行与干预:查询、事件回放、SSE 流、命令、取消、节点合同。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { REPORT_COLLABORATION_BASE, newIdempotencyKey, rcGet, rcRequest } from "./client";
import type {
CollaborationCommand,
CollaborationEvent,
CreateCommandPayload,
NodeContractDetail,
ReportRun,
StartRunPayload,
} from "./types";
export async function startRun(
sessionId: string,
payload: StartRunPayload,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRun> {
return rcRequest<ReportRun>(`/sessions/${encodeURIComponent(sessionId)}/runs`, {
method: "POST",
body: payload,
idempotencyKey,
expectedRevision,
}).then((res) => res as ReportRun);
}
export async function getRun(runId: string, signal?: AbortSignal): Promise<ReportRun | null> {
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}`, {
method: "GET",
signal,
nullOn404: true,
});
}
/** 历史事件回放(断线补拉 / 序号缺口补齐)。 */
export async function listRunEvents(
runId: string,
afterSeq: number,
signal?: AbortSignal,
): Promise<CollaborationEvent[]> {
const data = await rcGet<{ events: CollaborationEvent[] }>(
`/runs/${encodeURIComponent(runId)}/events?after_seq=${Math.max(0, afterSeq)}`,
signal,
);
return data.events ?? [];
}
export async function cancelRun(
runId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportRun> {
return rcRequest<ReportRun>(`/runs/${encodeURIComponent(runId)}/cancel`, {
method: "POST",
body: {},
idempotencyKey,
}).then((res) => res as ReportRun);
}
export async function getNodeContract(
runId: string,
nodeId: string,
signal?: AbortSignal,
): Promise<NodeContractDetail> {
return rcGet<NodeContractDetail>(
`/runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/contract`,
signal,
);
}
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
export async function createCommand(
runId: string,
payload: CreateCommandPayload,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(`/runs/${encodeURIComponent(runId)}/commands`, {
method: "POST",
body: payload,
idempotencyKey,
}).then((res) => res as CollaborationCommand);
}
export async function confirmCommand(
runId: string,
commandId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/confirm`,
{ method: "POST", body: {}, idempotencyKey },
).then((res) => res as CollaborationCommand);
}
export async function cancelCommand(
runId: string,
commandId: string,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationCommand> {
return rcRequest<CollaborationCommand>(
`/runs/${encodeURIComponent(runId)}/commands/${encodeURIComponent(commandId)}/cancel`,
{ method: "POST",
body: {},
idempotencyKey },
).then((res) => res as CollaborationCommand);
}
// ── SSE 订阅 ─────────────────────────────────────────────────────────────────
export interface RunStreamHandlers {
onEvent: (event: CollaborationEvent) => void;
onOpen?: () => void;
/** 服务端正常关流(终态)。 */
onClose?: () => void;
/** 传输层异常(触发重连)。 */
onError?: (message: string) => void;
}
/**
* 订阅 run 的 durable SSE 流。支持 `after_seq` 与 `Last-Event-ID`。
*
* 返回 close 函数:幂等,调用后中断连接且不再触发回调。
* 注意:fetch 流式读取无法携带 EventSource 的自动重连,重连由
* useReportCollaborationStream 以指数退避驱动。
*/
export function openRunStream(
runId: string,
afterSeq: number,
handlers: RunStreamHandlers,
): () => void {
const controller = new AbortController();
let closed = false;
const close = () => {
if (closed) return;
closed = true;
controller.abort();
};
void (async () => {
try {
const res = await apiFetch(
`${REPORT_COLLABORATION_BASE()}/runs/${encodeURIComponent(runId)}/stream?after_seq=${Math.max(0, afterSeq)}`,
{ method: "GET", signal: controller.signal, headers: { Accept: "text/event-stream" } },
);
if (!res.ok || !res.body) {
if (!closed) handlers.onError?.(`stream failed (${res.status})`);
return;
}
handlers.onOpen?.();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let dataLines: string[] = [];
let lastEventId: string | undefined;
const flushData = () => {
if (dataLines.length === 0) return;
const json = dataLines.join("\n").trim();
dataLines = [];
if (!json) return;
try {
const event = JSON.parse(json) as CollaborationEvent;
if (lastEventId) event.eventId = event.eventId || lastEventId;
handlers.onEvent(event);
} catch {
// 非 JSON 帧(如注释/心跳文本)忽略,不中断连接。
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.replace(/\r$/, "");
if (line === "") {
flushData();
continue;
}
if (line.startsWith("id:")) {
lastEventId = line.slice(3).trim() || undefined;
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
// `event:` / `:` 注释行不影响 data 解析。
}
}
flushData();
if (!closed) handlers.onClose?.();
} catch (err) {
if (!closed && !controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return close;
}
@@ -0,0 +1,84 @@
/**
* 会话 CRUD 与消息读写(`/api/report-collaboration/sessions`)。
*/
import { newIdempotencyKey, rcGet, rcRequest } from "./client";
import type {
CollaborationMessage,
CreateSessionPayload,
ReportSession,
ReportSessionSummary,
SendMessagePayload,
SessionSnapshot,
UpdateSessionPayload,
} from "./types";
export async function listSessions(signal?: AbortSignal): Promise<ReportSessionSummary[]> {
const data = await rcGet<{ sessions: ReportSessionSummary[] }>("/sessions", signal);
return data.sessions ?? [];
}
export async function getSessionSnapshot(
sessionId: string,
signal?: AbortSignal,
): Promise<SessionSnapshot | null> {
return rcRequest<SessionSnapshot>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "GET",
signal,
nullOn404: true,
});
}
export async function createSession(
payload: CreateSessionPayload = {},
idempotencyKey = newIdempotencyKey(),
): Promise<ReportSession> {
return rcRequest<ReportSession>("/sessions", {
method: "POST",
body: payload,
idempotencyKey,
}).then((res) => res as ReportSession);
}
export async function updateSession(
sessionId: string,
patch: UpdateSessionPayload,
expectedRevision?: number | null,
idempotencyKey = newIdempotencyKey(),
): Promise<ReportSession | null> {
return rcRequest<ReportSession>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "PATCH",
body: patch,
idempotencyKey,
expectedRevision,
nullOn404: true,
});
}
export async function deleteSession(sessionId: string): Promise<void> {
await rcRequest<void>(`/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE",
});
}
export async function listMessages(
sessionId: string,
signal?: AbortSignal,
): Promise<CollaborationMessage[]> {
const data = await rcGet<{ messages: CollaborationMessage[] }>(
`/sessions/${encodeURIComponent(sessionId)}/messages`,
signal,
);
return data.messages ?? [];
}
export async function sendMessage(
sessionId: string,
payload: SendMessagePayload,
idempotencyKey = newIdempotencyKey(),
): Promise<CollaborationMessage> {
return rcRequest<CollaborationMessage>(
`/sessions/${encodeURIComponent(sessionId)}/messages`,
{ method: "POST", body: payload, idempotencyKey },
).then((res) => res as CollaborationMessage);
}
@@ -0,0 +1,709 @@
/**
* 报告协作工作台(AgentScope 多智能体)—— 前后端共享领域类型。
*
* 契约来源:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md` §5/§7。
* 字段与后端 Pydantic 合同保持 snake_case 一一对应;前端只在 selector/adapter
* 层做投影,不再二次推断状态。
*
* 后端尚未实现时本层即为冻结契约(RC-FE-004 前置条件),修改字段需同步两份计划文档。
*/
// ── 会话 ─────────────────────────────────────────────────────────────────────
/** 会话状态机(后端 §6 sessions.status)。 */
export type ReportSessionStatus =
| "empty"
| "clarifying"
| "planning"
| "proposal_ready"
| "running"
| "awaiting_input"
| "reviewing"
| "completed"
| "failed"
| "cancelled";
export interface ReportSession {
id: string;
title: string;
status: ReportSessionStatus;
requirement_revision: number;
selected_plan_id: string | null;
active_run_id: string | null;
current_report_version_id: string | null;
created_at: string;
updated_at: string;
}
/** 会话列表条目(轻量,无 messages)。 */
export type ReportSessionSummary = ReportSession;
// ── 需求快照 ─────────────────────────────────────────────────────────────────
export interface ReportRequirementSnapshot {
topic: string;
objective?: string | null;
audience?: string | null;
language?: string | null;
time_range?: string | null;
geography?: string | null;
required_angles: string[];
excluded_angles: string[];
source_constraints?: string | null;
report_structure_id?: string | null;
length_target?: string | null;
tone?: string | null;
deadline_hint?: string | null;
quality_priority?: string | null;
revision: number;
}
// ── 消息镜像(ConversationReplica)──────────────────────────────────────────
export type CollaborationMessageRole = "human" | "ai" | "tool";
/** ai 消息上的工具调用(args 流式期间为部分 JSON 字符串,完成后为对象)。 */
export interface CollaborationToolCall {
id: string;
name: string;
args: string | Record<string, unknown>;
}
/**
* 后端 `report_collaboration_messages` 行的镜像:用户可见的 assistant 文本、
* TeamSay、`tool_calls` 与 `tool` 返回都落在这同一张时间线上。
*/
export interface CollaborationMessage {
message_id: string;
role: CollaborationMessageRole;
content: string;
/** 成员显示名(TeamSay / 成员回复 / tool 名)。 */
name?: string | null;
tool_calls?: CollaborationToolCall[];
tool_call_id?: string | null;
/** 并行阶段稳定归属;协调者广播可为 null(共享上下文)。 */
agent_run_id?: string | null;
node_run_id?: string | null;
phase_id?: string | null;
visibility: "visible" | "internal";
metadata?: Record<string, unknown> | null;
command_id?: string | null;
created_at: string;
/** null = 仍在流式输出。 */
completed_at?: string | null;
}
// ── 候选计划 ─────────────────────────────────────────────────────────────────
export type ReportPlanStatus =
| "proposed"
| "selected"
| "superseded"
| "rejected"
| "executing"
| "completed";
export type ReportCostLevel = "low" | "medium" | "high";
export interface PlanRole {
key: string;
display_name: string;
responsibility: string;
angle?: string | null;
}
export interface QualityGate {
id: string;
label: string;
description?: string | null;
}
export interface PlanValidation {
ok: boolean;
errors: string[];
}
/** 计划图节点:节点合同的用户可见摘要(不含执行结果)。 */
export interface PlanNode {
id: string;
label: string;
role_key?: string | null;
role_display_name?: string | null;
angle?: string | null;
depends_on: string[];
/** 合同摘要;完整合同走 `GET /runs/{run_id}/nodes/{node_id}/contract`。 */
mission?: string | null;
output_artifact_type?: string | null;
input_artifact_types?: string[];
allowed_tools?: string[];
acceptance_criteria?: string[];
}
export interface PlanEdge {
source: string;
target: string;
label?: string | null;
}
export interface ReportPlanCandidate {
id: string;
proposal_group_id: string;
title: string;
strategy: string;
summary: string;
rationale: string;
recommended?: boolean;
estimated_duration_seconds: number;
estimated_cost_level: ReportCostLevel;
requirement_revision: number;
roles: PlanRole[];
nodes: PlanNode[];
edges: PlanEdge[];
quality_gates: QualityGate[];
report_structure_id: string | null;
validation: PlanValidation;
revision: number;
status: ReportPlanStatus;
}
// ── 节点运行状态 ─────────────────────────────────────────────────────────────
/** 后端 §5.5 状态机:completed 只能经 reopened 重新入队。 */
export type CollaborationNodeStatus =
| "planned"
| "queued"
| "running"
| "validating"
| "completed"
| "failed"
| "awaiting_input"
| "cancel_requested"
| "cancelled"
| "superseded"
| "reopened"
| "interrupted";
export interface NodeAwaitingInput {
reason: string;
question: string;
impact?: string[];
}
export interface NodeAttemptState {
attempt: number;
status: CollaborationNodeStatus;
started_at?: string | null;
ended_at?: string | null;
error?: string | null;
}
export interface CollaborationNodeState {
node_id: string;
label: string;
role_display_name?: string | null;
angle?: string | null;
status: CollaborationNodeStatus;
attempt: number;
progress?: string | null;
awaiting_input?: NodeAwaitingInput | null;
error?: string | null;
attempts: NodeAttemptState[];
}
// ── 智能体成员(并行阶段稳定身份)────────────────────────────────────────────
export type AgentRunStatus =
| "pending"
| "running"
| "waiting_input"
| "completed"
| "failed"
| "cancelled";
export interface AgentRun {
agent_run_id: string;
node_run_id?: string | null;
phase_id?: string | null;
role: string;
display_name: string;
avatar_key?: string | null;
status: AgentRunStatus;
started_at?: string | null;
ended_at?: string | null;
}
// ── Run ──────────────────────────────────────────────────────────────────────
export type ReportRunStatus =
| "queued"
| "planning"
| "running"
| "awaiting_input"
| "reviewing"
| "completed"
| "failed"
| "cancelled"
| "recovering";
export interface ReportRun {
id: string;
session_id: string;
plan_id: string;
status: ReportRunStatus;
cancel_requested: boolean;
next_event_seq: number;
current_phase?: string | null;
usage?: Record<string, unknown>;
budget_exhausted_reason?: string | null;
created_at: string;
updated_at: string;
}
// ── 用户命令(全过程干预)────────────────────────────────────────────────────
export type CommandIntent =
| "answer_question"
| "update_requirements"
| "add_angle"
| "research_again"
| "reanalyze"
| "rerun_node"
| "rewrite_section"
| "polish_report"
| "ask_report"
| "replan"
| "cancel";
export type CommandStatus =
| "pending"
| "classified"
| "confirmation_required"
| "accepted"
| "accepted_waiting_boundary"
| "executing"
| "completed"
| "failed"
| "cancelled"
| "rejected";
export interface CollaborationCommand {
command_id: string;
idempotency_key: string;
text: string;
intent?: CommandIntent | null;
confidence?: number | null;
target_type?: string | null;
target_ids: string[];
normalized_instruction?: string | null;
requires_confirmation: boolean;
cost_level?: ReportCostLevel | null;
reason?: string | null;
impact_nodes?: string[];
status: CommandStatus;
expected_revision?: number | null;
error?: string | null;
created_at: string;
updated_at?: string | null;
}
// ── 成果物 / 来源 / 报告版本 ────────────────────────────────────────────────
export type ArtifactValidationStatus =
| "pending"
| "validated"
| "rejected"
| "superseded";
export interface CollaborationArtifact {
id: string;
run_id: string;
node_id: string;
attempt: number;
artifact_type: string;
schema_version: number;
validation_status: ArtifactValidationStatus;
/** 用户可见摘要(如「12 条来源,8 条近 90 天」);不含正文。 */
summary?: string | null;
created_at: string;
}
export interface ReportSource {
id: string;
url?: string | null;
title: string;
published_at?: string | null;
added_at: string;
agent_run_id?: string | null;
}
export type ReportChangeType =
| "initial"
| "section_rewrite"
| "polish"
| "restore"
| "revision";
export interface ReportVersion {
id: string;
session_id: string;
version: number;
parent_version_id: string | null;
change_type: ReportChangeType;
change_summary?: string | null;
markdown: string;
source_index?: ReportSource[];
created_at: string;
is_head?: boolean;
}
export interface ReportRewriteProposal {
id: string;
session_id: string;
section?: string | null;
instruction: string;
status: "proposed" | "applied" | "dismissed";
markdown?: string | null;
created_at: string;
}
// ── 澄清 ─────────────────────────────────────────────────────────────────────
export interface ClarificationRecord {
id: string;
questions: string[];
impact?: string[];
resolved: boolean;
created_at: string;
}
// ── SSE 事件协议 ─────────────────────────────────────────────────────────────
export type CollaborationEventType =
// 会话与消息镜像
| "session.snapshot"
| "message.created"
| "message.delta"
| "message.completed"
| "tool_call.created"
| "tool_call.delta"
| "tool_call.completed"
| "tool_result.created"
| "tool_result.delta"
| "tool_result.completed"
| "team_message.created"
| "team_message.delta"
| "team_message.completed"
// 需求与澄清
| "requirement.updated"
| "clarification.requested"
| "clarification.resolved"
// 计划
| "plan.progress"
| "plan.proposed"
| "plan.selected"
// Run / 节点
| "run.status.changed"
| "node.created"
| "node.status.changed"
| "node.progress"
| "node.attempt.created"
// 成果与来源
| "artifact.created"
| "artifact.validated"
| "artifact.rejected"
| "source.added"
// 命令
| "command.classified"
| "command.confirmation.required"
| "command.accepted"
| "command.completed"
| "command.failed"
| "command.cancelled"
// 报告与评审
| "report.delta"
| "report.version.created"
| "review.created"
// 其他
| "error"
| "heartbeat";
export interface CollaborationEvent<T = unknown> {
eventId: string;
sessionId: string;
runId?: string | null;
seq: number;
type: CollaborationEventType;
timestamp: string;
data: T;
}
// ── 事件 payload ─────────────────────────────────────────────────────────────
export interface MessageEventPayload {
message: CollaborationMessage;
}
export interface MessageDeltaPayload {
message_id: string;
agent_run_id?: string | null;
node_run_id?: string | null;
delta: string;
/** 思考过程增量,写入消息 `metadata.reasoning_content`,由适配器映射到问答 CoT。 */
reasoning_delta?: string;
}
export interface MessageCompletedPayload {
message_id: string;
agent_run_id?: string | null;
content?: string;
completed_at?: string;
}
export interface ToolCallCreatedPayload {
message_id: string;
agent_run_id?: string | null;
tool_call: CollaborationToolCall;
}
export interface ToolCallDeltaPayload {
message_id: string;
tool_call_id: string;
args_delta: string;
}
export interface ToolCallCompletedPayload {
message_id: string;
tool_call: CollaborationToolCall;
}
export interface ToolResultCreatedPayload {
/** 工具返回作为独立 tool 消息的稳定 id(缺省时前端以 `tool:{tool_call_id}` 派生)。 */
message_id?: string;
tool_call_id: string;
name: string;
agent_run_id?: string | null;
node_run_id?: string | null;
phase_id?: string | null;
content?: string;
}
export interface ToolResultDeltaPayload {
message_id?: string;
tool_call_id: string;
delta: string;
}
export interface ToolResultCompletedPayload {
message_id?: string;
tool_call_id: string;
content?: string;
}
export interface RequirementUpdatedPayload {
requirement: ReportRequirementSnapshot;
}
export interface ClarificationRequestedPayload {
clarification: ClarificationRecord;
/** 同时镜像为 ask_clarification tool 消息(由后端 ConversationReplica 发出)。 */
message?: CollaborationMessage;
}
export interface ClarificationResolvedPayload {
clarification_id: string;
}
export interface PlanProgressPayload {
stage: string;
detail?: string | null;
}
export interface PlanProposedPayload {
plans: ReportPlanCandidate[];
}
export interface PlanSelectedPayload {
plan_id: string;
}
export interface RunStatusChangedPayload {
status: ReportRunStatus;
phase?: string | null;
reason?: string | null;
}
export interface NodeCreatedPayload {
node: CollaborationNodeState;
}
export interface NodeStatusChangedPayload {
node_id: string;
status: CollaborationNodeStatus;
attempt: number;
awaiting_input?: NodeAwaitingInput | null;
error?: string | null;
}
export interface NodeProgressPayload {
node_id: string;
progress: string;
}
export interface NodeAttemptCreatedPayload {
node_id: string;
attempt: number;
reason?: string | null;
}
export interface ArtifactCreatedPayload {
artifact: CollaborationArtifact;
}
export interface ArtifactValidatedPayload {
artifact_id: string;
}
export interface ArtifactRejectedPayload {
artifact_id: string;
reasons: string[];
}
export interface SourceAddedPayload {
source: ReportSource;
}
export interface CommandClassifiedPayload {
command: CollaborationCommand;
}
export interface CommandConfirmationRequiredPayload {
command: CollaborationCommand;
}
export interface CommandAcceptedPayload {
command_id: string;
waiting_boundary: boolean;
impact_nodes?: string[];
}
export interface CommandCompletedPayload {
command_id: string;
result_summary?: string | null;
report_version_id?: string | null;
}
export interface CommandFailedPayload {
command_id: string;
error: string;
}
export interface CommandCancelledPayload {
command_id: string;
command?: CollaborationCommand;
}
export interface ReportDeltaPayload {
draft_id?: string | null;
delta: string;
}
export interface ReportVersionCreatedPayload {
version: ReportVersion;
}
export interface ReviewCreatedPayload {
review: {
reviewer: string;
verdict: "pass" | "revise";
issues: { severity: string; target: string; requirement: string }[];
};
}
export interface ErrorEventPayload {
message: string;
recover_hint?: string | null;
node_id?: string | null;
}
export interface HeartbeatPayload {
ts?: string;
}
// ── 快照 / 请求体 ────────────────────────────────────────────────────────────
/** `GET /sessions/{id}` 返回的完整快照:前端唯一事实源投影。 */
export interface SessionSnapshot {
session: ReportSession;
messages: CollaborationMessage[];
plans: ReportPlanCandidate[];
run: ReportRun | null;
nodes: CollaborationNodeState[];
agent_runs: AgentRun[];
commands: CollaborationCommand[];
report_versions: ReportVersion[];
/** 已持久化事件的最大 seq;SSE 以 after_seq=last_seq 续接。 */
last_seq: number;
}
export interface CreateSessionPayload {
title?: string;
}
export interface UpdateSessionPayload {
title?: string;
}
export interface SendMessagePayload {
text: string;
/** 从节点抽屉 / 并行选择器带出的目标上下文。 */
target_node_id?: string | null;
agent_run_id?: string | null;
}
export interface StartRunPayload {
plan_id: string;
}
export interface CreateCommandPayload {
text: string;
target_node_id?: string | null;
agent_run_id?: string | null;
}
export interface CreateRewritePayload {
instruction: string;
section?: string | null;
}
/** 统一错误模型(client 层抛出的 ApiError 携带这些字段)。 */
export interface ApiErrorDetail {
code?: string;
message: string;
detail?: unknown;
}
/**
* `GET /runs/{run_id}/nodes/{node_id}/contract` 返回的节点合同详情。
* 只含职责与输入输出合同,绝不含执行结果正文(后端 §7.1)。
*/
export interface NodeContractDetail {
node_id: string;
label: string;
role: string | null;
angle: string | null;
status: CollaborationNodeStatus;
attempt: number;
mission: string;
input_artifact_types: string[];
source_policy?: string | null;
output_artifact_type: string;
output_structure?: string | null;
acceptance_criteria: string[];
allowed_tools: string[];
forbidden?: string[];
time_budget_seconds?: number | null;
token_budget?: number | null;
max_attempts: number;
upstream: string[];
downstream: string[];
}
@@ -0,0 +1,133 @@
"use client";
import { memo } from "react";
import { Check, Loader2, X } from "lucide-react";
import type { AgentRun } from "@/report-collaboration/api/types";
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
interface AgentStageSelectorProps {
/** 当前正在执行的成员;空数组且无 pinned 时不渲染。 */
tabs: AgentRun[];
/** 点击画布节点后钉住的历史成员(波次结束后回看步骤条)。 */
pinnedAgent?: AgentRun | null;
phaseLabel?: string | null;
focusedAgentRunId: string | null;
onFocus: (agentRunId: string) => void;
/** 取消钉住,回到全量时间线。 */
onShowAll?: () => void;
/** 正在流式输出的成员。 */
streamingAgentRunIds: Set<string>;
/** 请求用户协助(待回复)的成员。 */
awaitingAgentRunIds: Set<string>;
}
function avatarStatus(
agentRun: AgentRun,
streaming: boolean,
awaiting: boolean,
): "running" | "awaiting" | "done" | "error" | "queued" {
if (awaiting) return "awaiting";
if (agentRun.status === "failed" || agentRun.status === "cancelled") return "error";
if (agentRun.status === "completed") return "done";
if (agentRun.status === "running" || streaming) return "running";
return "queued";
}
function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: string): string {
const suffix =
status === "running"
? "进行中"
: status === "awaiting"
? "待回复"
: status === "done"
? "已完成"
: status === "error"
? "失败"
: "排队中";
return `${displayName}(${suffix})`;
}
/**
* 执行中头像条(对齐会商第二步 sandbox):
* 单个模型节点与并行波次都展示;点头像切换到该成员的步骤条 / 模型输出。
* 未选中成员的事件继续接收并持久更新。
*/
export const AgentStageSelector = memo(function AgentStageSelector({
tabs,
pinnedAgent = null,
phaseLabel,
focusedAgentRunId,
onFocus,
onShowAll,
streamingAgentRunIds,
awaitingAgentRunIds,
}: AgentStageSelectorProps) {
const visibleTabs =
tabs.length > 0
? tabs
: pinnedAgent
? [pinnedAgent]
: [];
if (visibleTabs.length === 0) return null;
const focused = focusedAgentRunId ?? visibleTabs[0]?.agent_run_id;
const runningCount = visibleTabs.filter((tab) => {
const status = avatarStatus(
tab,
streamingAgentRunIds.has(tab.agent_run_id),
awaitingAgentRunIds.has(tab.agent_run_id),
);
return status === "running" || status === "awaiting";
}).length;
const stripLabel =
phaseLabel ??
(visibleTabs.length > 1 ? `并行 ${visibleTabs.length} 席` : visibleTabs[0]?.display_name ?? "当前节点");
return (
<div className="rc-agent-tabs" role="tablist" aria-label="执行成员">
<div className="rc-agent-strip-meta">
<span>{stripLabel}</span>
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
</div>
<div className="rc-agent-avatars">
{visibleTabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const status = avatarStatus(tab, streaming, awaiting);
const active = focused === tab.agent_run_id;
return (
<button
key={tab.agent_run_id}
type="button"
role="tab"
aria-selected={active}
data-active={active}
data-status={status}
data-role={tab.role}
className="rc-agent-avatar"
title={statusLabel(status, tab.display_name)}
aria-label={statusLabel(status, tab.display_name)}
onClick={() => onFocus(tab.agent_run_id)}
>
{status === "running" && <span className="rc-agent-avatar-ping" aria-hidden />}
<span className="rc-agent-avatar-face" aria-hidden>
{roleIcon(tab.role)}
</span>
<span className="rc-agent-avatar-badge" data-status={status} aria-hidden>
{status === "running" && <Loader2 className="rc-spin size-2.5" />}
{status === "awaiting" && <span className="rc-agent-avatar-dot" />}
{status === "done" && <Check className="size-2.5" strokeWidth={3} />}
{status === "error" && <X className="size-2.5" strokeWidth={3} />}
</span>
</button>
);
})}
</div>
{onShowAll && (tabs.length <= 1 || Boolean(pinnedAgent)) ? (
<button type="button" className="rc-btn rc-agent-show-all" onClick={onShowAll}>
全部对话
</button>
) : null}
</div>
);
});
@@ -0,0 +1,41 @@
"use client";
import { useI18n } from "@/core/i18n/hooks";
import type { RcConnectionStatus } from "@/report-collaboration/hooks/useReportCollaborationStream";
const STATUS_TEXT: Record<RcConnectionStatus, string> = {
idle: "未连接",
connecting: "连接中…",
live: "实时",
reconnecting: "重连中…",
closed: "已关闭",
error: "连接异常",
};
/** 底部连接状态栏:连接态 + run + 事件游标(aria-live 播报)。 */
export function ConnectionStatusBar({
status,
runId,
lastSeq,
message,
}: {
status: RcConnectionStatus;
runId: string | null;
lastSeq: number;
message?: string;
}) {
const { t } = useI18n();
void t;
return (
<footer className="rc-status-bar" aria-live="polite">
<span>
<span className="rc-status-dot" data-status={status} />
SSE:{STATUS_TEXT[status]}
{status === "reconnecting" && message ? `(${message.slice(0, 80)})` : ""}
</span>
<span>run:{runId ? runId.slice(0, 18) : "—"}</span>
<span>事件 seq:{lastSeq}</span>
</footer>
);
}
@@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, Check, Loader2, X } from "lucide-react";
import type { CollaborationCommand } from "@/report-collaboration/api/types";
const INTENT_LABELS: Record<string, string> = {
answer_question: "只读问答",
update_requirements: "更新需求",
add_angle: "新增分析角度",
research_again: "重新检索",
reanalyze: "重新分析",
rerun_node: "重跑节点",
rewrite_section: "重写章节",
polish_report: "全文润色",
ask_report: "报告问答",
replan: "重新规划",
cancel: "取消",
};
interface InterventionCardProps {
command: CollaborationCommand;
busy: boolean;
onConfirm: (commandId: string) => void;
onCancel: (commandId: string) => void;
}
/**
* 命令确认卡:高成本 / 低置信度命令需要用户显式确认。
* 确认/取消都只是提交命令,不改变节点完成状态(状态只由后端状态机驱动)。
*/
export function InterventionCard({ command, busy, onConfirm, onCancel }: InterventionCardProps) {
return (
<div className="rc-intervention-card">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-hidden />
<div className="min-w-0">
<div className="text-[13px] font-semibold">需要确认:{INTENT_LABELS[command.intent ?? ""] ?? "修改操作"}</div>
<p className="mt-0.5 text-[12px] leading-5 text-[var(--rc-text-secondary)]">
「{command.text}」
</p>
</div>
</div>
<ul className="list-disc pl-8 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
<li>
影响范围:
{command.impact_nodes && command.impact_nodes.length > 0
? command.impact_nodes.join("、") + " 及其下游"
: "由协调者判定"}
</li>
<li>
成本:{command.cost_level === "high" ? "高(触发重新检索/重写)" : command.cost_level === "low" ? "低" : "中"}
</li>
{command.reason ? <li>理由:{command.reason}</li> : null}
</ul>
<div className="flex justify-end gap-2">
<button type="button" className="rc-btn" disabled={busy} onClick={() => onCancel(command.command_id)}>
<X className="size-3.5" aria-hidden />
取消
</button>
<button
type="button"
className="rc-btn rc-btn-primary"
disabled={busy}
onClick={() => onConfirm(command.command_id)}
>
{busy ? <Loader2 className="rc-spin size-3.5" /> : <Check className="size-3.5" aria-hidden />}
确认执行
</button>
</div>
</div>
);
}
@@ -0,0 +1,280 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Ban, ChevronDown, Target, X } from "lucide-react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { NodeContractDetail } from "@/report-collaboration/api/types";
import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode";
export interface NodeInspectorFallback {
label: string;
status: string;
role?: string | null;
angle?: string | null;
kind?: ReportFlowNodeKind;
mission?: string | null;
input_artifact_types?: string[];
output_artifact_type?: string | null;
allowed_tools?: string[];
acceptance_criteria?: string[];
upstream?: string[];
downstream?: string[];
}
interface NodeInspectorDrawerProps {
open: boolean;
nodeId: string | null;
runId: string | null;
/** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */
fallback?: NodeInspectorFallback | null;
onClose: () => void;
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
onComment: (nodeId: string, nodeName: string) => void;
}
const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const;
type SectionKey = (typeof SECTION_KEYS)[number];
/**
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
* 执行过程统一在左侧 MessageList 查看。
*/
export function NodeInspectorDrawer({
open,
nodeId,
runId,
fallback,
onClose,
onComment,
}: NodeInspectorDrawerProps) {
const [contract, setContract] = useState<NodeContractDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
mission: true,
input: true,
output: true,
tools: true,
graph: true,
});
useEffect(() => {
if (!open || !nodeId || !runId) {
setContract(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
getReportCollaborationDriver()
.getNodeContract(runId, nodeId)
.then((detail) => {
if (!cancelled) setContract(detail);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, nodeId, runId]);
const view = useMemo(() => {
return {
label: contract?.label ?? fallback?.label ?? nodeId ?? "",
status: contract?.status ?? fallback?.status ?? "planned",
role: contract?.role ?? fallback?.role ?? null,
angle: contract?.angle ?? fallback?.angle ?? null,
kind: fallback?.kind ?? "agent",
mission: contract?.mission ?? fallback?.mission ?? null,
input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [],
output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null,
allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [],
acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [],
upstream: contract?.upstream ?? fallback?.upstream ?? [],
downstream: contract?.downstream ?? fallback?.downstream ?? [],
forbidden: contract?.forbidden ?? [],
max_attempts: contract?.max_attempts ?? null,
time_budget_seconds: contract?.time_budget_seconds ?? null,
token_budget: contract?.token_budget ?? null,
source_policy: contract?.source_policy ?? null,
};
}, [contract, fallback, nodeId]);
if (!open || !nodeId) return null;
const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label);
const showLoading = loading && !hasBody;
const toggle = (key: SectionKey) => {
setOpenSections((current) => ({ ...current, [key]: !current[key] }));
};
return (
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${view.label}`}>
<header className="rc-drawer-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(view.role, view.kind)}
</span>
<div className="min-w-0 flex-1">
<div className="rc-drawer-title">{view.label}</div>
<div className="rc-drawer-subtitle">
{view.role ? <span>{view.role}</span> : null}
{view.angle ? <span>· {view.angle}</span> : null}
<span>· {statusText(view.status)}</span>
</div>
</div>
<button type="button" className="rc-drawer-icon-btn" onClick={onClose} aria-label="关闭节点说明">
<X className="size-4" />
</button>
</header>
<div className="rc-drawer-body">
{showLoading ? (
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
) : error && !hasBody ? (
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
读取节点合同失败:{error}
</div>
) : (
<>
<DrawerSection title="节点职责" open={openSections.mission} onToggle={() => toggle("mission")}>
<p className="rc-drawer-text">{view.mission || "暂无职责说明"}</p>
</DrawerSection>
<DrawerSection title="输入" open={openSections.input} onToggle={() => toggle("input")}>
<ul className="rc-drawer-list">
{view.input_artifact_types.length > 0 ? (
view.input_artifact_types.map((type) => (
<li key={type}>
<span className="rc-flow-pill rc-flow-pill-input">{shortArtifact(type)}</span>
</li>
))
) : (
<li className="text-[var(--rc-text-secondary)]">无上游成果要求</li>
)}
{view.upstream.length > 0 ? <li>上游节点:{view.upstream.join("、")}</li> : null}
{view.source_policy ? <li>资料策略:{view.source_policy}</li> : null}
</ul>
</DrawerSection>
<DrawerSection title="输出" open={openSections.output} onToggle={() => toggle("output")}>
<ul className="rc-drawer-list">
<li>
成果类型:
{view.output_artifact_type ? (
<span className="rc-flow-pill rc-flow-pill-output">{shortArtifact(view.output_artifact_type)}</span>
) : (
"未指定"
)}
</li>
{view.acceptance_criteria.map((criterion) => (
<li key={criterion}>验收:{criterion}</li>
))}
</ul>
</DrawerSection>
<DrawerSection title="技能" open={openSections.tools} onToggle={() => toggle("tools")}>
<div className="flex flex-wrap gap-1">
{view.allowed_tools.length > 0 ? (
view.allowed_tools.map((tool) => (
<span key={tool} className="rc-flow-pill rc-flow-pill-neutral">
{tool}
</span>
))
) : (
<span className="text-[12px] text-[var(--rc-text-secondary)]">未配置技能</span>
)}
</div>
{view.forbidden.length > 0 ? (
<ul className="rc-drawer-list mt-2">
{view.forbidden.map((rule) => (
<li key={rule}>
<Ban className="mr-1 inline size-3" aria-hidden />
{rule}
</li>
))}
</ul>
) : null}
{view.max_attempts != null ? (
<p className="mt-2 text-[12px] text-[var(--rc-text-secondary)]">
预算:至多 {view.max_attempts} 次尝试
{view.time_budget_seconds ? ` · ${view.time_budget_seconds}s` : ""}
{view.token_budget ? ` · ${view.token_budget} tokens` : ""}
</p>
) : null}
</DrawerSection>
<DrawerSection title="上下游" open={openSections.graph} onToggle={() => toggle("graph")}>
<p className="rc-drawer-text">
{view.downstream.length > 0 ? `下游:${view.downstream.join("、")}` : "终端节点(无下游)"}
</p>
</DrawerSection>
</>
)}
</div>
<footer className="rc-drawer-footer">
<button
type="button"
className="rc-btn rc-btn-primary w-full justify-center"
onClick={() => onComment(nodeId, view.label)}
>
<Target className="size-3.5" aria-hidden />
在对话中对此节点提出意见
</button>
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
</p>
</footer>
</aside>
);
}
function DrawerSection({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="rc-drawer-section" data-open={open}>
<button type="button" className="rc-drawer-section-toggle" onClick={onToggle} aria-expanded={open}>
<ChevronDown className="rc-drawer-chevron" aria-hidden />
<span>{title}</span>
</button>
{open ? <div className="rc-drawer-section-body">{children}</div> : null}
</section>
);
}
function shortArtifact(value: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
}
function statusText(status: string): string {
const map: Record<string, string> = {
planned: "规划中",
queued: "排队中",
running: "运行中",
validating: "校验中",
completed: "已完成",
failed: "失败",
awaiting_input: "等待用户",
cancel_requested: "取消中",
cancelled: "已取消",
superseded: "已被替代",
reopened: "已重开",
interrupted: "已中断",
};
return map[status] ?? status;
}
@@ -0,0 +1,77 @@
"use client";
import { ArrowLeft, FileText, MessageSquarePlus, Square } from "lucide-react";
import type { PagePhase } from "@/report-collaboration/state/selectors";
const PHASE_BADGES: Record<PagePhase, { label: string; color: string; bg: string }> = {
empty: { label: "新会话", color: "var(--rc-text-secondary)", bg: "transparent" },
clarifying: { label: "澄清需求", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
planning: { label: "生成方案", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
proposal_ready: { label: "待确认方案", color: "var(--rc-accent)", bg: "rgb(var(--rc-accent-rgb) / 10%)" },
running: { label: "协作中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
awaiting_input: { label: "等待输入", color: "var(--rc-waiting)", bg: "rgba(217, 119, 6, 0.1)" },
reviewing: { label: "审稿中", color: "var(--rc-running)", bg: "rgba(37, 99, 235, 0.1)" },
completed: { label: "已完成", color: "var(--rc-success)", bg: "rgba(22, 163, 74, 0.1)" },
failed: { label: "失败", color: "var(--rc-danger)", bg: "rgba(220, 38, 38, 0.1)" },
cancelled: { label: "已取消", color: "var(--rc-text-secondary)", bg: "transparent" },
};
interface ReportCollaborationHeaderProps {
title: string;
phase: PagePhase;
onBack: () => void;
onNewSession: () => void;
onOpenReport: () => void;
hasReport: boolean;
running: boolean;
onCancelRun: () => void;
extra?: React.ReactNode;
}
/** 顶栏:返回 / 标题+状态 / 报告 / 停止 / 新建(右侧由布局注入窄屏切换等)。 */
export function ReportCollaborationHeader({
title,
phase,
onBack,
onNewSession,
onOpenReport,
hasReport,
running,
onCancelRun,
extra,
}: ReportCollaborationHeaderProps) {
const badge = PHASE_BADGES[phase] ?? PHASE_BADGES.empty;
return (
<header className="rc-header">
<div className="flex min-w-0 items-center gap-2">
<button type="button" className="rc-btn !min-h-8 !px-2" onClick={onBack} aria-label="返回工作台">
<ArrowLeft className="size-4" />
</button>
<span className="rc-header-title" title={title}>
报告协作 / {title}
</span>
<span className="rc-chip" style={{ background: badge.bg, color: badge.color }}>
{badge.label}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{extra}
<button type="button" className="rc-btn" onClick={onOpenReport} disabled={!hasReport}>
<FileText className="size-3.5" aria-hidden />
报告
</button>
{running ? (
<button type="button" className="rc-btn rc-btn-danger" onClick={onCancelRun}>
<Square className="size-3.5" aria-hidden />
停止
</button>
) : null}
<button type="button" className="rc-btn" onClick={onNewSession}>
<MessageSquarePlus className="size-3.5" aria-hidden />
新建
</button>
</div>
</header>
);
}
@@ -0,0 +1,166 @@
"use client";
import { useCallback, useRef } from "react";
import { MessageSquare, Workflow } from "lucide-react";
import {
CANVAS_MIN_WIDTH,
PANEL_MIN_WIDTH,
RESIZE_KEYBOARD_STEP,
type FocusMode,
type NarrowView,
type UseReportPanelLayoutResult,
} from "@/report-collaboration/hooks/useReportPanelLayout";
interface ReportCollaborationLayoutProps {
layout: UseReportPanelLayoutResult;
header: React.ReactNode;
conversation: React.ReactNode;
canvas: React.ReactNode;
statusBar: React.ReactNode;
}
/**
* 固定两栏骨架:左对话 / 右画布 + 可拖拽分隔条(pointer + 方向键 + Home/End)
* + 窄屏单面板切换 + 「专注」模式。节点抽屉与预览 Dialog 由画布侧覆盖渲染。
*/
export function ReportCollaborationLayout({
layout,
header,
conversation,
canvas,
statusBar,
}: ReportCollaborationLayoutProps) {
const { conversationWidth, setConversationWidth, nudgeConversationWidth, isNarrow, narrowView, setNarrowView, focusMode, bodyRef } =
layout;
const dragStart = useRef<{ x: number; width: number } | null>(null);
const onPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragStart.current = { x: event.clientX, width: conversationWidth };
},
[conversationWidth],
);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!dragStart.current || event.buttons !== 1) return;
setConversationWidth(dragStart.current.width + (event.clientX - dragStart.current.x));
},
[setConversationWidth],
);
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStart.current = null;
}, []);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const max = Math.max(
PANEL_MIN_WIDTH,
Math.min(720, (bodyRef.current?.clientWidth ?? 720) - CANVAS_MIN_WIDTH),
);
if (event.key === "ArrowLeft") {
event.preventDefault();
nudgeConversationWidth(-RESIZE_KEYBOARD_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
nudgeConversationWidth(RESIZE_KEYBOARD_STEP);
} else if (event.key === "Home") {
event.preventDefault();
setConversationWidth(PANEL_MIN_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
setConversationWidth(max);
}
},
[bodyRef, nudgeConversationWidth, setConversationWidth],
);
const showConversation = isNarrow ? narrowView === "conversation" : focusMode !== "canvas";
const showCanvas = isNarrow ? narrowView === "canvas" : focusMode !== "conversation";
return (
<div className="rc-root">
{header}
<div className="rc-body" ref={bodyRef}>
{showConversation ? (
<section
className="rc-conversation-panel"
style={{ width: isNarrow ? "100%" : conversationWidth, flexShrink: isNarrow ? 1 : 0 }}
aria-label="对话面板"
>
{conversation}
</section>
) : null}
{!isNarrow && showConversation && showCanvas ? (
<div
role="separator"
aria-orientation="vertical"
aria-label="调整对话与画布宽度"
aria-valuenow={conversationWidth}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={720}
tabIndex={0}
className="rc-resizer"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
/>
) : null}
{showCanvas ? (
<section className="rc-canvas-panel" aria-label="协作流程画布">
{isNarrow ? (
<div className="absolute left-2 top-2 z-10">
<NarrowSwitch narrowView={narrowView} setNarrowView={setNarrowView} />
</div>
) : null}
{canvas}
</section>
) : null}
</div>
{statusBar}
</div>
);
}
export function NarrowSwitch({
narrowView,
setNarrowView,
}: {
narrowView: NarrowView;
setNarrowView: (view: NarrowView) => void;
}) {
return (
<div className="rc-narrow-switch" role="tablist" aria-label="视图切换">
<button
type="button"
role="tab"
aria-selected={narrowView === "conversation"}
data-active={narrowView === "conversation"}
onClick={() => setNarrowView("conversation")}
>
<MessageSquare className="mr-1 inline size-3" aria-hidden />
对话
</button>
<button
type="button"
role="tab"
aria-selected={narrowView === "canvas"}
data-active={narrowView === "canvas"}
onClick={() => setNarrowView("canvas")}
>
<Workflow className="mr-1 inline size-3" aria-hidden />
流程
</button>
</div>
);
}
export type { FocusMode };
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CornerDownLeft, Loader2, Send, Target, X } from "lucide-react";
import type { PagePhase } from "@/report-collaboration/state/selectors";
export interface ComposerTarget {
nodeId: string;
nodeName: string;
agentRunId?: string | null;
}
interface ReportComposerProps {
phase: PagePhase;
busy: boolean;
disabled?: boolean;
target: ComposerTarget | null;
onClearTarget: () => void;
onSend: (text: string) => void;
/** 递增以请求聚焦(节点抽屉「在对话中提出意见」)。 */
focusSignal: number;
}
const PHASE_HINTS: Record<PagePhase, { placeholder: string; hint: string; tone?: "warning" | "danger" }> = {
empty: {
placeholder: "描述你的报告需求,例如:撰写一份新能源汽车主要品牌市场趋势分析报告…",
hint: "输入需求后回车发送",
},
clarifying: {
placeholder: "回答澄清问题,或补充你的要求…",
hint: "优先回答上方的澄清问题",
tone: "warning",
},
planning: {
placeholder: "补充要求(将在规划安全点生效)…",
hint: "正在生成候选方案,补充要求会在安全点应用",
},
proposal_ready: {
placeholder: "可以先预览方案;确认后点「开始协作」…",
hint: "点击方案卡可在右侧预览对应协作流程",
},
running: {
placeholder: "运行中可补充要求:增加角度 / 重新检索 / 重写章节…",
hint: "协调者正在跟踪运行,你的输入会被分类并在安全点生效",
},
awaiting_input: {
placeholder: "回答等待中的问题…",
hint: "有节点正在等待你的输入,优先处理等待卡",
tone: "warning",
},
reviewing: {
placeholder: "审稿进行中,可补充要求…",
hint: "审稿意见会出现在时间线中",
},
completed: {
placeholder: "报告已完成:继续问答、重写章节、全文润色…",
hint: "修改会生成新版本,不直接覆盖正式报告",
},
failed: {
placeholder: "通过对话重试,或取消后从已有成果继续…",
hint: "运行失败,可在对话中要求重试",
tone: "danger",
},
cancelled: {
placeholder: "可从已有成果继续,或生成新方案…",
hint: "运行已取消,已完成的成果仍然可用",
},
};
/** 统一输入框:按页面状态提示;Enter 发送 / Shift+Enter 换行。 */
export function ReportComposer({
phase,
busy,
disabled = false,
target,
onClearTarget,
onSend,
focusSignal,
}: ReportComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hint = PHASE_HINTS[phase] ?? PHASE_HINTS.empty;
useEffect(() => {
if (focusSignal > 0) {
textareaRef.current?.focus();
}
}, [focusSignal]);
const submit = () => {
const trimmed = value.trim();
if (!trimmed || busy || disabled) return;
onSend(trimmed);
setValue("");
};
return (
<div className="rc-composer">
<div className="rc-composer-hint" data-tone={hint.tone} aria-live="polite">
{busy ? <Loader2 className="rc-spin size-3.5" aria-hidden /> : null}
<span>{busy ? "正在识别修改范围…" : hint.hint}</span>
</div>
{target ? (
<div className="mb-1 flex items-center gap-2 px-1">
<span className="rc-composer-target">
<Target className="size-3" aria-hidden />
对节点「{target.nodeName}」提出意见
</span>
<button
type="button"
className="text-[11px] text-[var(--rc-text-secondary)] hover:text-[var(--rc-danger)]"
onClick={onClearTarget}
aria-label="清除节点目标"
>
<X className="size-3.5" />
</button>
</div>
) : null}
<div className="relative">
<textarea
ref={textareaRef}
className="min-h-24 w-full resize-none rounded-lg border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] px-3 py-2.5 pr-14 text-[13px] leading-6 outline-none transition-colors focus:border-[var(--rc-accent)] disabled:opacity-60"
rows={4}
value={value}
disabled={disabled}
placeholder={hint.placeholder}
aria-label="报告协作输入框"
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
submit();
}
}}
/>
<button
type="button"
className="absolute bottom-2.5 right-2.5 inline-flex size-9 items-center justify-center rounded-lg text-white shadow disabled:opacity-50"
style={{ background: "var(--rc-accent)" }}
disabled={!value.trim() || busy || disabled}
onClick={submit}
aria-label="发送"
>
{busy ? <Loader2 className="rc-spin size-4" /> : <Send className="size-4" />}
</button>
</div>
<div className="mt-1 flex items-center justify-end gap-1 pr-1 text-[10px] text-[var(--rc-text-secondary)]">
<CornerDownLeft className="size-3" aria-hidden />
<span>Enter 发送 · Shift+Enter 换行</span>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useMemo } from "react";
import { MessagesSquare } from "lucide-react";
import type { Message } from "@langchain/langgraph-sdk";
import { MessageList } from "@/components/workspace/messages/message-list";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
interface ReportConversationPanelProps {
sessionId: string;
thread: BaseStream<AgentThreadState>;
businessCardSlot: (message: Message) => React.ReactNode;
/** 会商第二步式说话人壳;不传则只渲染原生 MessageList。 */
speakerShell?: (messages: Message[], children: React.ReactNode) => React.ReactNode;
stageSelector?: React.ReactNode;
composer: React.ReactNode;
loading: boolean;
emptyHint?: React.ReactNode;
/** 并行切换成员时重挂消息列表,避免沿用上一成员的滚动位置。 */
listKey?: string;
}
/**
* 左侧对话面板:外层结构参考 Coze,内部严格复用 DeerFlow `MessageList`
* (原生 assistant / tool_calls / tool 消息、引用与协助卡渲染)。
*/
export function ReportConversationPanel({
sessionId,
thread,
businessCardSlot,
speakerShell,
stageSelector,
composer,
loading,
emptyHint,
listKey,
}: ReportConversationPanelProps) {
const hasMessages = thread.messages.length > 0;
const content = useMemo(() => {
if (loading && !hasMessages) {
return (
<div className="rc-empty" aria-busy="true">
<MessagesSquare className="size-8 opacity-30" aria-hidden />
<p>正在恢复会话…</p>
</div>
);
}
if (!hasMessages) {
return <div className="rc-empty">{emptyHint ?? "输入报告需求开始协作"}</div>;
}
return (
<ThreadContext.Provider value={{ thread, isMock: true }}>
<MessageList
key={listKey ?? sessionId}
className="min-h-0 flex-1"
threadId={sessionId}
thread={thread}
paddingBottom={24}
scrollButtonClassName="bottom-2"
contentClassName={speakerShell ? "gap-6 pt-6" : undefined}
businessCardSlot={businessCardSlot}
speakerShell={speakerShell}
/>
</ThreadContext.Provider>
);
}, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, speakerShell, thread]);
return (
<SubtasksProvider>
<div className="flex min-h-0 flex-1 flex-col">
{stageSelector}
{content}
{composer}
</div>
</SubtasksProvider>
);
}
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Controls,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
type DefaultEdgeOptions,
type Edge,
type Node,
type NodeTypes,
useReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Workflow } from "lucide-react";
import type {
CollaborationNodeState,
ReportPlanCandidate,
} from "@/report-collaboration/api/types";
import {
CollaborationFlowNode,
type ReportFlowNodeDatum,
type ReportFlowNodeKind,
} from "@/report-collaboration/components/ReportFlowNode";
const NODE_TYPES: NodeTypes = { collaboration: CollaborationFlowNode };
/** 左到右 rank 间距(节点左缘到下一列左缘),对齐 Coze 整理布局的横向留白。 */
const COLUMN_GAP = 360;
/** 并行节点高度估算(去掉输入/输出后的卡片高度),用于重心对齐。 */
const NODE_HEIGHT = 128;
/** 同层并行节点之间的垂直间隙,对齐 Coze Dagre nodesep 观感。 */
const NODE_GAP = 52;
const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
type: "default",
style: { stroke: "var(--rc-flow-edge)", strokeWidth: 2 },
};
interface ReportFlowCanvasProps {
plan: ReportPlanCandidate | null;
nodeStates: Record<string, CollaborationNodeState>;
selectedNodeId: string | null;
onSelectNode: (nodeId: string | null) => void;
/** 空态文案(无方案时)。 */
emptyHint?: string;
}
/** 分层 + 重心对齐:同层并行节点绕父节点垂直中心对称,对齐 Coze「整理节点」Dagre LR。 */
function layoutPlan(plan: ReportPlanCandidate): Map<string, { x: number; y: number }> {
const depthById = new Map<string, number>();
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const nodeIds = new Set(nodeById.keys());
const visit = (nodeId: string, seen: Set<string>): number => {
if (depthById.has(nodeId)) return depthById.get(nodeId)!;
if (seen.has(nodeId)) return 0;
seen.add(nodeId);
const node = nodeById.get(nodeId);
const depth =
node && node.depends_on.length > 0
? Math.max(...node.depends_on.filter((dep) => nodeIds.has(dep)).map((dep) => visit(dep, seen))) + 1
: 0;
depthById.set(nodeId, depth);
return depth;
};
for (const node of plan.nodes) visit(node.id, new Set());
const layers = new Map<number, string[]>();
for (const node of plan.nodes) {
const depth = depthById.get(node.id) ?? 0;
layers.set(depth, [...(layers.get(depth) ?? []), node.id]);
}
const yById = new Map<string, number>();
const spacing = NODE_HEIGHT + NODE_GAP;
const depths = [...layers.keys()].sort((a, b) => a - b);
for (const depth of depths) {
const ids = layers.get(depth) ?? [];
const desired = ids.map((id) => {
const node = nodeById.get(id);
const parents = (node?.depends_on ?? []).filter((dep) => yById.has(dep));
if (parents.length === 0) return 0;
return parents.reduce((sum, parentId) => sum + (yById.get(parentId) ?? 0), 0) / parents.length;
});
const packed = ids.map((_, index) => index * spacing);
const packedMean = packed.reduce((sum, value) => sum + value, 0) / Math.max(packed.length, 1);
const desiredMean = desired.reduce((sum, value) => sum + value, 0) / Math.max(desired.length, 1);
const shift = desiredMean - packedMean;
ids.forEach((id, index) => {
yById.set(id, packed[index] + shift);
});
}
const positions = new Map<string, { x: number; y: number }>();
for (const node of plan.nodes) {
const depth = depthById.get(node.id) ?? 0;
positions.set(node.id, { x: depth * COLUMN_GAP, y: yById.get(node.id) ?? 0 });
}
return positions;
}
function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint }: ReportFlowCanvasProps) {
const rf = useReactFlow();
const autoPositions = useMemo(() => (plan ? layoutPlan(plan) : new Map<string, { x: number; y: number }>()), [plan]);
// 拖动位置是纯浏览器展示状态:per-plan 保存,方案切换即清理。
const draggedRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const [draggedVersion, setDraggedVersion] = useState(0);
const planKey = plan?.id ?? "none";
useEffect(() => {
draggedRef.current = new Map();
setDraggedVersion((v) => v + 1);
}, [planKey]);
useEffect(() => {
// 方案切换后适配视图(下一帧节点已挂载)。
const timer = window.setTimeout(() => rf.fitView({ padding: 0.18, duration: 240 }), 60);
return () => window.clearTimeout(timer);
}, [planKey, rf]);
const nodes: Node[] = useMemo(() => {
if (!plan) return [];
void draggedVersion;
const outgoing = new Set(plan.edges.map((edge) => edge.source));
return plan.nodes.map((node) => {
const state = nodeStates[node.id];
const kind: ReportFlowNodeKind =
node.depends_on.length === 0 ? "start" : outgoing.has(node.id) ? "agent" : "end";
const datum: ReportFlowNodeDatum = {
nodeId: node.id,
label: node.label,
roleName: state?.role_display_name ?? node.role_display_name ?? null,
angle: state?.angle ?? node.angle ?? null,
status: state?.status ?? "planned",
attempt: state?.attempt ?? 0,
selected: selectedNodeId === node.id,
kind,
tools: node.allowed_tools ?? [],
};
const dragged = draggedRef.current.get(node.id);
const position = dragged ?? autoPositions.get(node.id) ?? { x: 0, y: 0 };
return {
id: node.id,
type: "collaboration",
position,
data: datum,
draggable: true,
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
}, [plan, nodeStates, selectedNodeId, autoPositions, draggedVersion]);
const edges: Edge[] = useMemo(() => {
if (!plan) return [];
return plan.edges.map((edge, index) => {
const targetState = nodeStates[edge.target];
const active = targetState?.status === "running" || targetState?.status === "validating";
return {
id: `${edge.source}->${edge.target}-${index}`,
source: edge.source,
target: edge.target,
label: edge.label ?? undefined,
type: "default",
animated: false,
className: active ? "rc-edge-active" : "rc-edge",
style: {
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
strokeWidth: 2,
},
};
});
}, [plan, nodeStates]);
if (!plan) {
return (
<div className="rc-empty">
<Workflow className="size-10 opacity-30" aria-hidden />
<p>{emptyHint ?? "确认候选方案后,这里会展示协作流程图"}</p>
</div>
);
}
return (
<ReactFlow
className="rc-flow"
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
onNodeClick={(_, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
onNodeDragStop={(_, node) => {
draggedRef.current.set(node.id, { x: node.position.x, y: node.position.y });
setDraggedVersion((v) => v + 1);
}}
onNodeDoubleClick={(_, node) => onSelectNode(node.id)}
fitView
fitViewOptions={{ padding: 0.18 }}
minZoom={0.55}
maxZoom={1.75}
nodesConnectable={false}
elementsSelectable
proOptions={{ hideAttribution: true }}
aria-label="协作流程画布"
>
<Controls position="bottom-left" orientation="horizontal" showInteractive={false} />
<MiniMap
position="bottom-right"
pannable
zoomable
className="rc-flow-minimap"
nodeColor={() => "var(--rc-flow-edge)"}
maskColor="var(--rc-flow-minimap-mask)"
/>
</ReactFlow>
);
}
/** 协作流程画布:只读展示 + 本地拖动;重跑/纠偏一律通过左侧对话完成。 */
export function ReportFlowCanvas(props: ReportFlowCanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,150 @@
"use client";
import { memo, type ReactNode } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
AlertTriangle,
Bot,
Check,
CircleArrowRight,
CircleDashed,
FileCheck,
Flag,
LineChart,
Loader2,
PenLine,
RefreshCw,
Search,
ShieldCheck,
ShieldQuestion,
Users,
X,
} from "lucide-react";
import type { CollaborationNodeStatus } from "@/report-collaboration/api/types";
export type ReportFlowNodeKind = "start" | "end" | "agent";
/** 画布自定义节点数据(由 ReportFlowCanvas 组装)。 */
export interface ReportFlowNodeDatum {
nodeId: string;
label: string;
roleName?: string | null;
angle?: string | null;
status: CollaborationNodeStatus;
attempt: number;
selected: boolean;
kind: ReportFlowNodeKind;
tools: string[];
/** xyflow Node.data 需要 Record 兼容的索引签名。 */
[key: string]: unknown;
}
export type ReportFlowNodeType = "collaboration";
const STATUS_META: Record<
CollaborationNodeStatus,
{ text: string; color: string; icon: ReactNode }
> = {
planned: { text: "规划中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
queued: { text: "排队中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
running: { text: "运行中", color: "var(--rc-running)", icon: <Loader2 className="rc-spin size-3.5" /> },
validating: { text: "校验中", color: "var(--rc-running)", icon: <ShieldQuestion className="size-3.5" /> },
completed: { text: "已完成", color: "var(--rc-success)", icon: <Check className="size-3.5" /> },
failed: { text: "失败", color: "var(--rc-danger)", icon: <X className="size-3.5" /> },
awaiting_input: { text: "等待用户", color: "var(--rc-waiting)", icon: <AlertTriangle className="size-3.5" /> },
cancel_requested: { text: "取消中", color: "var(--rc-waiting)", icon: <X className="size-3.5" /> },
cancelled: { text: "已取消", color: "var(--rc-text-secondary)", icon: <X className="size-3.5" /> },
superseded: { text: "已被替代", color: "var(--rc-text-secondary)", icon: <RefreshCw className="size-3.5" /> },
reopened: { text: "已重开", color: "var(--rc-waiting)", icon: <RefreshCw className="size-3.5" /> },
interrupted: { text: "已中断", color: "var(--rc-waiting)", icon: <AlertTriangle className="size-3.5" /> },
};
export function roleIcon(roleName: string | null | undefined, kind: ReportFlowNodeKind = "agent"): ReactNode {
if (kind === "start") return <CircleArrowRight />;
if (kind === "end") return <Flag />;
switch (roleName) {
case "协调者":
return <Users />;
case "研究员":
return <Search />;
case "核验员":
return <ShieldCheck />;
case "分析员":
return <LineChart />;
case "撰写员":
return <PenLine />;
case "审稿员":
return <FileCheck />;
default:
return <Bot />;
}
}
function FlowPill({ children }: { children: ReactNode }) {
return <span className="rc-flow-pill rc-flow-pill-neutral">{children}</span>;
}
function FieldRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<div className="rc-flow-node-field-label">{label}</div>
<div className="rc-flow-node-field-value">{children}</div>
</>
);
}
function CollaborationFlowNodeComponent({ data }: NodeProps) {
const node = data as unknown as ReportFlowNodeDatum;
const meta = STATUS_META[node.status] ?? STATUS_META.planned;
const statusText = node.attempt > 1 ? `${meta.text} · 第 ${node.attempt} 次` : meta.text;
return (
<div
className="rc-flow-node"
data-status={node.status}
data-selected={node.selected}
data-kind={node.kind}
data-role={node.roleName ?? ""}
tabIndex={0}
role="button"
aria-label={`节点 ${node.label},${statusText}`}
>
<Handle type="target" position={Position.Left} className="rc-flow-handle" isConnectable={false} />
<div className="rc-flow-node-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(node.roleName, node.kind)}
</span>
<span className="rc-flow-node-title">{node.label}</span>
</div>
<div className="rc-flow-node-body">
{node.roleName ? (
<FieldRow label="角色">
<FlowPill>{node.roleName}</FlowPill>
</FieldRow>
) : null}
{node.angle ? (
<FieldRow label="角度">
<FlowPill>{node.angle}</FlowPill>
</FieldRow>
) : null}
{node.tools.length > 0 ? (
<FieldRow label="技能">
{node.tools.map((tool) => (
<FlowPill key={`tool-${tool}`}>{tool}</FlowPill>
))}
</FieldRow>
) : null}
<FieldRow label="状态">
<span className="rc-flow-node-status" style={{ color: meta.color }}>
{meta.icon}
<span>{statusText}</span>
</span>
</FieldRow>
</div>
<Handle type="source" position={Position.Right} className="rc-flow-handle" isConnectable={false} />
</div>
);
}
export const CollaborationFlowNode = memo(CollaborationFlowNodeComponent);
@@ -0,0 +1,188 @@
"use client";
import { Clock, Gauge, Play, RefreshCw, ShieldCheck, Star, Users } from "lucide-react";
import type { ReportPlanCandidate } from "@/report-collaboration/api/types";
import { cn } from "@/lib/utils";
interface ReportPlanCardsProps {
plans: ReportPlanCandidate[];
selectedPlanId: string | null;
previewPlanId: string | null;
onPreview: (planId: string) => void;
onSelect: (planId: string) => void;
onStart: (planId: string) => void;
onRegenerate: () => void;
selectBusy: boolean;
startBusy: boolean;
regenerateBusy: boolean;
}
const STRATEGY_LABELS: Record<string, string> = {
balanced_review: "均衡审阅",
parallel_depth: "深度并行",
focused_fast: "快速聚焦",
};
function costLabel(level: string | null | undefined): string {
if (level === "low") return "成本 低";
if (level === "high") return "成本 高";
return "成本 中";
}
function durationLabel(seconds: number): string {
if (seconds >= 3600) return `约 ${Math.round(seconds / 3600)} 小时`;
if (seconds >= 60) return `约 ${Math.round(seconds / 60)} 分钟`;
return `约 ${seconds} 秒`;
}
/**
* 候选方案卡组:点击卡片只切换右侧画布预览(纯展示状态),
* 「采用此方案」才写服务器 selectedPlanId,「开始协作」创建 run。
*/
export function ReportPlanCards({
plans,
selectedPlanId,
previewPlanId,
onPreview,
onSelect,
onStart,
onRegenerate,
selectBusy,
startBusy,
regenerateBusy,
}: ReportPlanCardsProps) {
if (plans.length === 0) return null;
return (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-[13px] font-semibold">我整理了 {plans.length} 条候选协作方案</span>
<button
type="button"
className="rc-btn"
onClick={onRegenerate}
disabled={regenerateBusy}
>
<RefreshCw className={cn("size-3.5", regenerateBusy && "rc-spin")} aria-hidden />
重新生成
</button>
</div>
<p className="px-1 text-[12px] text-[var(--rc-text-secondary)]">
点击卡片在右侧预览协作流程;采用后才会按该方案执行。
</p>
{plans.map((plan, index) => {
const isSelected = plan.id === selectedPlanId;
const isPreviewed = plan.id === (previewPlanId ?? selectedPlanId ?? plans[0]?.id);
const invalid = plan.validation && plan.validation.ok === false;
return (
<article
key={plan.id}
className={cn(
"rc-plan-card",
isPreviewed && "rc-plan-card-selected",
invalid && "rc-plan-card-disabled",
)}
onClick={() => onPreview(plan.id)}
role="button"
tabIndex={0}
aria-pressed={isPreviewed}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onPreview(plan.id);
}
}}
>
<div className="flex items-center gap-2">
<span className="rc-chip">
{plan.recommended || index === 0 ? (
<>
<Star className="size-3" aria-hidden /> 推荐
</>
) : (
`方案 ${index + 1}`
)}
</span>
<span className="rc-chip" style={{ background: "var(--rc-bg-secondary)", color: "var(--rc-text-secondary)" }}>
{STRATEGY_LABELS[plan.strategy] ?? plan.strategy}
</span>
{isSelected ? (
<span className="rc-chip" style={{ background: "rgba(22, 163, 74, 0.1)", color: "var(--rc-success)" }}>
已采用
</span>
) : null}
</div>
<h3 className="text-[13px] font-semibold leading-5">{plan.title}</h3>
<p className="text-[12px] leading-5 text-[var(--rc-text-secondary)]">{plan.summary}</p>
<p className="text-[11px] leading-4 text-[var(--rc-text-secondary)] opacity-80">{plan.rationale}</p>
<div className="flex flex-wrap items-center gap-1">
<Users className="size-3 text-[var(--rc-text-secondary)]" aria-hidden />
{plan.roles.slice(0, 6).map((role) => (
<span key={role.key} className="rc-chip">
{role.display_name}
</span>
))}
{plan.roles.length > 6 ? (
<span className="text-[11px] text-[var(--rc-text-secondary)]">+{plan.roles.length - 6}</span>
) : null}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-[var(--rc-text-secondary)]">
<span className="inline-flex items-center gap-1">
<Clock className="size-3" aria-hidden />
{durationLabel(plan.estimated_duration_seconds)}
</span>
<span className="inline-flex items-center gap-1">
<Gauge className="size-3" aria-hidden />
{costLabel(plan.estimated_cost_level)}
</span>
<span className="inline-flex items-center gap-1">
<ShieldCheck className="size-3" aria-hidden />
{plan.nodes.length} 节点 · {plan.quality_gates.length} 质量门
</span>
</div>
{invalid ? (
<div
className="rounded-md px-2 py-1 text-[11px]"
style={{
background: "rgba(220, 38, 38, 0.08)",
color: "var(--rc-danger)",
}}
>
方案校验未通过:{plan.validation.errors.join(";")}
</div>
) : null}
<div className="mt-1 grid grid-cols-2 gap-2">
<button
type="button"
className="rc-btn"
disabled={isSelected || selectBusy || invalid}
onClick={(event) => {
event.stopPropagation();
onSelect(plan.id);
}}
>
{isSelected ? "已采用" : "采用此方案"}
</button>
<button
type="button"
className="rc-btn rc-btn-primary"
disabled={!isSelected || startBusy || invalid}
onClick={(event) => {
event.stopPropagation();
onStart(plan.id);
}}
>
<Play className="size-3.5" aria-hidden />
开始协作
</button>
</div>
</article>
);
})}
</div>
);
}
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import { FileText, History, Loader2, RotateCcw } from "lucide-react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
import type { ReportVersion } from "@/report-collaboration/api/types";
interface ReportPreviewDialogProps {
open: boolean;
onClose: () => void;
versions: ReportVersion[];
activeVersionId: string | null;
onSelectVersion: (versionId: string) => void;
onRestore: (versionId: string) => void;
restoreBusy: boolean;
}
function downloadMarkdownFile(title: string, markdown: string): void {
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title || "报告"}.md`;
link.click();
URL.revokeObjectURL(url);
}
/** 大尺寸报告预览 Dialog:Markdown 渲染 + 版本切换 + 下载/恢复。 */
export function ReportPreviewDialog({
open,
onClose,
versions,
activeVersionId,
onSelectVersion,
onRestore,
restoreBusy,
}: ReportPreviewDialogProps) {
const [downloading, setDownloading] = useState<"md" | "docx" | null>(null);
if (!open) return null;
const active =
versions.find((version) => version.id === activeVersionId) ?? versions.at(-1) ?? null;
if (!active) return null;
const sorted = [...versions].sort((a, b) => b.version - a.version);
const isHead = active.is_head || active.id === versions.at(-1)?.id;
const handleDocx = async () => {
if (!active) return;
setDownloading("docx");
try {
await downloadMarkdownDocxFromBackend({
title: `报告 v${active.version}`,
markdown: active.markdown,
});
} finally {
setDownloading(null);
}
};
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/40 p-6"
role="dialog"
aria-modal="true"
aria-label="报告预览"
onClick={onClose}
>
<div
className="flex h-full max-h-[88vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-[var(--rc-border)] bg-[var(--rc-bg-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between gap-3 border-b border-[var(--rc-border)] px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<FileText className="size-4 shrink-0" style={{ color: "var(--rc-accent)" }} aria-hidden />
<span className="truncate text-[14px] font-semibold">报告预览 · v{active.version}</span>
{active.change_summary ? (
<span className="truncate text-[12px] text-[var(--rc-text-secondary)]">
{active.change_summary}
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<button type="button" className="rc-btn" onClick={() => downloadMarkdownFile(`报告_v${active.version}`, active.markdown)}>
下载 Markdown
</button>
<button type="button" className="rc-btn" onClick={() => void handleDocx()} disabled={downloading === "docx"}>
{downloading === "docx" ? <Loader2 className="rc-spin size-3.5" /> : null}
下载 Word
</button>
{!isHead ? (
<button
type="button"
className="rc-btn"
disabled={restoreBusy}
onClick={() => onRestore(active.id)}
title="恢复此版本(创建新的头版本,不删除历史)"
>
{restoreBusy ? <Loader2 className="rc-spin size-3.5" /> : <RotateCcw className="size-3.5" />}
恢复此版本
</button>
) : null}
<button type="button" className="rc-btn" onClick={onClose} aria-label="关闭预览">
关闭
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{sorted.length > 1 ? (
<nav className="w-44 shrink-0 overflow-y-auto border-r border-[var(--rc-border)] p-2" aria-label="版本历史">
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] font-semibold text-[var(--rc-text-secondary)]">
<History className="size-3" aria-hidden /> 版本
</div>
{sorted.map((version) => (
<button
key={version.id}
type="button"
className="rc-tab block w-full text-left"
data-active={version.id === active.id}
onClick={() => onSelectVersion(version.id)}
>
v{version.version}
{version.is_head ? "(当前)" : ""}
<span className="block text-[10px] font-normal opacity-70">
{version.change_type}
</span>
</button>
))}
</nav>
) : null}
<div className="min-w-0 flex-1 overflow-y-auto px-6 py-4">
<MarkdownContent
content={active.markdown}
className="rc-report-preview"
rehypePlugins={[]}
isLoading={false}
/>
</div>
</div>
</div>
</div>
);
}
/** MessageList 业务卡:报告入口卡(点击打开大尺寸预览 Dialog)。 */
export function ReportAvailableCard({
version,
versionCount,
onOpen,
}: {
version: ReportVersion;
versionCount: number;
onOpen: (versionId: string) => void;
}) {
return (
<div className="rc-report-card">
<div className="flex items-center gap-2">
<FileText className="size-4" style={{ color: "var(--rc-success)" }} aria-hidden />
<span className="text-[13px] font-semibold">报告 v{version.version} 已生成</span>
{versionCount > 1 ? (
<span className="rc-chip" style={{ background: "var(--rc-bg-secondary)", color: "var(--rc-text-secondary)" }}>
共 {versionCount} 个版本
</span>
) : null}
</div>
{version.change_summary ? (
<p className="text-[12px] text-[var(--rc-text-secondary)]">{version.change_summary}</p>
) : null}
<div className="flex gap-2">
<button type="button" className="rc-btn rc-btn-primary" onClick={() => onOpen(version.id)}>
预览报告
</button>
<button
type="button"
className="rc-btn"
onClick={() => downloadMarkdownFile(`报告_v${version.version}`, version.markdown)}
>
下载 Markdown
</button>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import { FilesIcon, XIcon } from "lucide-react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { ArtifactFileDetail, useArtifacts } from "@/components/workspace/artifacts";
/**
* 画布上的沙箱覆盖层:write_file 步骤条自动打开后,在右侧预览报告,
* 不另开第三栏。关闭后归还流程图。
*/
export function ReportSandboxOverlay({ threadId }: { threadId: string }) {
const { open, selectedArtifact, setOpen, deselect } = useArtifacts();
if (!open) return null;
const handleClose = () => {
deselect();
setOpen(false);
};
return (
<div className="rc-sandbox-overlay" role="dialog" aria-label="沙箱预览">
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
onClose={handleClose}
/>
) : (
<div className="relative flex size-full items-center justify-center bg-[var(--rc-bg-panel)]">
<Button
size="icon-sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={handleClose}
aria-label="关闭沙箱"
>
<XIcon />
</Button>
<ConversationEmptyState
icon={<FilesIcon />}
title="沙箱预览"
description="智能体写入文件后将在此预览"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { MessageBubble } from "@/roundtable-planning/components/MessageBubble";
import type { ReportSpeaker } from "@/report-collaboration/state/speaker";
interface ReportSpeakerShellProps {
speaker: ReportSpeaker;
onAvatarClick?: (agentRunId: string) => void;
avatarSelected?: boolean;
children: ReactNode;
}
/**
* 会商第二步气泡壳:头像 + 席位名 + 正文(步骤条 / 思考 / 输出仍走 MessageList)。
*/
export function ReportSpeakerShell({
speaker,
onAvatarClick,
avatarSelected = false,
children,
}: ReportSpeakerShellProps) {
const avatarInner = getAvatar(speaker.avatarType, "h-9 w-9 shrink-0");
const canFilter = Boolean(speaker.agentRunId && onAvatarClick);
const avatar = canFilter ? (
<button
type="button"
onClick={() => onAvatarClick?.(speaker.agentRunId!)}
aria-pressed={avatarSelected}
title={
avatarSelected
? "取消筛选,显示全部发言"
: `只看 ${desensitize(speaker.name)} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
avatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return (
<MessageBubble
avatar={avatar}
senderName={speaker.name}
badge={speaker.role && speaker.role !== speaker.name ? speaker.role : undefined}
time={speaker.time}
>
{children}
</MessageBubble>
);
}
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Loader2, MessageSquarePlus, Pencil, Trash2, X } from "lucide-react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { ReportSessionSummary } from "@/report-collaboration/api/types";
import { cn } from "@/lib/utils";
const STATUS_LABELS: Record<string, string> = {
empty: "空会话",
clarifying: "澄清需求",
planning: "生成方案中",
proposal_ready: "待确认方案",
running: "协作中",
awaiting_input: "等待输入",
reviewing: "审稿中",
completed: "已完成",
failed: "失败",
cancelled: "已取消",
};
/** 历史会话面板:切换 / 重命名 / 删除 / 新建。 */
export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: string | null }) {
const navigate = useNavigate();
const [sessions, setSessions] = useState<ReportSessionSummary[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [busyId, setBusyId] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const reload = useCallback(async (quiet = false) => {
if (!quiet) setLoading(true);
try {
const list = await getReportCollaborationDriver().listSessions();
setSessions(list);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const commitRename = async (sessionId: string) => {
const title = renameValue.trim();
setRenamingId(null);
if (!title) return;
setBusyId(sessionId);
try {
await getReportCollaborationDriver().updateSession(sessionId, { title });
await reload(true);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
};
const removeSession = async (sessionId: string) => {
if (!window.confirm("删除该会话将同时删除其消息、方案与报告版本,且不可恢复。确定删除?")) {
return;
}
setBusyId(sessionId);
try {
await getReportCollaborationDriver().deleteSession(sessionId);
if (activeSessionId === sessionId) {
navigate("/page/workspace/report-collaboration");
} else {
await reload(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
};
const clearAllSessions = async () => {
const list = sessions ?? [];
if (list.length === 0 || clearing) return;
if (
!window.confirm(
`将删除全部 ${list.length} 个历史会话(含消息、方案与报告版本),且不可恢复。确定清空?`,
)
) {
return;
}
setClearing(true);
try {
// 个别会话可能因运行中的协作删除失败(409 等),用 allSettled 尽量多删。
const results = await Promise.allSettled(
list.map((session) => getReportCollaborationDriver().deleteSession(session.id)),
);
const failedCount = results.filter((result) => result.status === "rejected").length;
const activeDeleted = list.some(
(session, index) => results[index].status === "fulfilled" && session.id === activeSessionId,
);
await reload(true);
if (failedCount > 0) {
setError(`已删除 ${list.length - failedCount} 个会话,${failedCount} 个删除失败(可能存在运行中的协作),可稍后重试。`);
} else {
setError(null);
}
if (activeDeleted) {
navigate("/page/workspace/report-collaboration");
}
} finally {
setClearing(false);
}
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between gap-2 px-3 py-2">
<span className="shrink-0 text-[12px] text-[var(--rc-text-secondary)]">
{loading ? "加载中…" : `${sessions?.length ?? 0} 个会话`}
</span>
<div className="flex min-w-0 items-center gap-1.5">
<button
type="button"
className="rc-btn"
disabled={clearing || !sessions?.length}
title={sessions?.length ? "删除全部历史会话" : "暂无历史会话"}
onClick={() => void clearAllSessions()}
>
{clearing ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Trash2 className="size-3.5" aria-hidden />
)}
清空历史
</button>
<button
type="button"
className="rc-btn"
onClick={() => navigate("/page/workspace/report-collaboration")}
>
<MessageSquarePlus className="size-3.5" aria-hidden />
新建会话
</button>
</div>
</div>
{error ? (
<p className="px-3 pb-1 text-[11px]" style={{ color: "var(--rc-danger)" }}>
{error}
</p>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{(sessions ?? []).map((session) => {
const active = session.id === activeSessionId;
return (
<div
key={session.id}
className={cn(
"group flex items-center gap-2 rounded-lg px-2.5 py-2 transition-colors",
active ? "bg-[var(--rc-accent-soft)]" : "hover:bg-[var(--rc-bg-secondary)]",
)}
>
{renamingId === session.id ? (
<>
<input
autoFocus
className="min-w-0 flex-1 rounded-md border border-[var(--rc-accent)] bg-[var(--rc-bg-panel)] px-2 py-1 text-[12px] outline-none"
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRename(session.id);
if (event.key === "Escape") setRenamingId(null);
}}
aria-label="会话标题"
/>
<button type="button" className="rc-btn !min-h-7 !px-1.5" onClick={() => void commitRename(session.id)} aria-label="保存标题">
<Check className="size-3.5" />
</button>
<button type="button" className="rc-btn !min-h-7 !px-1.5" onClick={() => setRenamingId(null)} aria-label="取消重命名">
<X className="size-3.5" />
</button>
</>
) : (
<>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => navigate(`/page/workspace/report-collaboration/${session.id}`)}
>
<div className="truncate text-[12px] font-medium">{session.title}</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--rc-text-secondary)]">
<span>{STATUS_LABELS[session.status] ?? session.status}</span>
<span>·</span>
<span>{formatTime(session.updated_at)}</span>
</div>
</button>
<span className="hidden shrink-0 items-center gap-1 group-hover:flex">
{busyId === session.id ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<>
<button
type="button"
className="rounded p-1 hover:bg-[var(--rc-bg-secondary)]"
aria-label="重命名会话"
onClick={() => {
setRenamingId(session.id);
setRenameValue(session.title);
}}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
className="rounded p-1 hover:bg-[var(--rc-bg-secondary)]"
aria-label="删除会话"
onClick={() => void removeSession(session.id)}
>
<Trash2 className="size-3.5" style={{ color: "var(--rc-danger)" }} />
</button>
</>
)}
</span>
</>
)}
</div>
);
})}
{!loading && (sessions?.length ?? 0) === 0 ? (
<p className="px-3 py-6 text-center text-[12px] text-[var(--rc-text-secondary)]">
暂无历史会话
</p>
) : null}
</div>
</div>
);
}
function formatTime(iso: string): string {
try {
const date = new Date(iso);
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
return sameDay
? date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
: date.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
} catch {
return "";
}
}
@@ -0,0 +1,70 @@
/**
* 消息组装 hook:状态 → 聚焦过滤 → LangGraph Message[] → BaseStream 外观。
*
* - 并行执行中只显示当前成员;结束后把各成员收成普通聊天记录,不重拉不改写。
* - 虚拟业务卡(方案组 / 命令确认 / 报告入口)由页面以 extraCards 传入,
* 经 MessageList 的 businessCardSlot 渲染。
*/
import { useMemo } from "react";
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "@/core/threads";
import type { CollaborationMessage } from "@/report-collaboration/api/types";
import type { CollaborationState } from "@/report-collaboration/state/event-reducer";
import {
buildBaseStreamFacade,
buildReportWriteFileMessages,
reportWriteFileBelongsToFocus,
toLangGraphMessages,
} from "@/report-collaboration/state/message-adapter";
import {
selectConversationMessages,
selectHeadReportVersion,
selectOrderedVisibleMessages,
selectWriterAgentRun,
} from "@/report-collaboration/state/selectors";
export interface UseReportCollaborationMessagesResult {
visibleMessages: CollaborationMessage[];
focusedMessages: CollaborationMessage[];
displayMessages: Message[];
displayThread: BaseStream<AgentThreadState>;
/** 存在未完成消息(流式中)。 */
streaming: boolean;
}
export function useReportCollaborationMessages(
state: CollaborationState,
focusedAgentRunId: string | null,
extraCards: Message[],
extraLoading: boolean,
): UseReportCollaborationMessagesResult {
return useMemo(() => {
const visibleMessages = selectOrderedVisibleMessages(state);
const focusedMessages = selectConversationMessages(state, focusedAgentRunId);
const base = toLangGraphMessages(focusedMessages);
const writeFiles = reportWriteFileBelongsToFocus(
buildReportWriteFileMessages({
sessionId: state.sessionId,
markdown: state.reportDraft?.markdown || selectHeadReportVersion(state)?.markdown || "",
writing: Boolean(state.reportDraft),
writer: selectWriterAgentRun(state),
}),
focusedAgentRunId,
);
const displayMessages =
extraCards.length > 0 || writeFiles.length > 0
? [...base, ...writeFiles, ...extraCards]
: base;
const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages;
const streaming =
streamSource.some((message) => !message.completed_at && message.role !== "human") ||
Boolean(state.reportDraft) ||
extraLoading;
const displayThread = buildBaseStreamFacade(displayMessages, streaming);
return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming };
}, [extraCards, extraLoading, focusedAgentRunId, state]);
}
@@ -0,0 +1,403 @@
/**
* 会话编排 hook:快照加载 → SSE 续接 → 合帧 reducer → 动作派发。
*
* 页面唯一的数据入口;纯展示状态(预览方案、聚焦成员、面板宽度)不在此层。
*/
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { newIdempotencyKey } from "@/report-collaboration/api/client";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type {
CollaborationEvent,
CollaborationMessage,
SessionSnapshot,
} from "@/report-collaboration/api/types";
import {
applyCollaborationEvents,
applySessionSnapshot,
createInitialCollaborationState,
hasSeqGap,
insertOptimisticUserMessage,
type CollaborationState,
} from "@/report-collaboration/state/event-reducer";
import { normalizeCollaborationEvent } from "@/report-collaboration/state/event-normalizer";
import {
useReportCollaborationStream,
type RcStreamStatus,
} from "@/report-collaboration/hooks/useReportCollaborationStream";
const EVENT_FLUSH_MS = 50;
const PRE_RUN_POLL_MS = 2500;
type RcAction =
| { type: "snapshot"; snapshot: SessionSnapshot }
| { type: "events"; events: CollaborationEvent[] }
| { type: "optimistic"; message: CollaborationMessage };
function rcReducer(state: CollaborationState, action: RcAction): CollaborationState {
switch (action.type) {
case "snapshot":
return applySessionSnapshot(state, action.snapshot);
case "events":
return applyCollaborationEvents(state, action.events);
case "optimistic":
return insertOptimisticUserMessage(state, action.message);
}
}
export interface RcInterventionTarget {
nodeId?: string | null;
agentRunId?: string | null;
}
export interface UseReportCollaborationSessionResult {
state: CollaborationState;
streamStatus: RcStreamStatus;
snapshotLoading: boolean;
snapshotError: string | null;
/** 最近一次动作错误(422/409/429/网络),供页面 toast 展示。 */
actionError: string | null;
clearActionError: () => void;
sendBusy: boolean;
/** 全过程发送:run 活动中走 commands,其余走 session messages。 */
send: (text: string, target?: RcInterventionTarget) => Promise<void>;
requestPlans: () => Promise<void>;
selectPlan: (planId: string) => Promise<void>;
startRun: (planId: string) => Promise<void>;
cancelRun: () => Promise<void>;
confirmCommand: (commandId: string) => Promise<void>;
cancelCommand: (commandId: string) => Promise<void>;
refreshSnapshot: () => void;
}
function optimisticUserMessage(text: string, idempotencyKey: string): CollaborationMessage {
return {
message_id: `pending:${idempotencyKey}`,
role: "human",
content: text,
visibility: "visible",
metadata: { optimistic: true, idempotency_key: idempotencyKey },
created_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
};
}
export function isRunActive(status: string | undefined | null): boolean {
return (
status === "queued" ||
status === "planning" ||
status === "running" ||
status === "awaiting_input" ||
status === "reviewing" ||
status === "recovering"
);
}
export function useReportCollaborationSession(sessionId: string): UseReportCollaborationSessionResult {
const driver = useMemo(() => getReportCollaborationDriver(), []);
const [state, dispatch] = useReducer(rcReducer, sessionId, createInitialCollaborationState);
const [streamStatus, setStreamStatus] = useState<RcStreamStatus>({ status: "idle", attempts: 0 });
const [snapshotLoading, setSnapshotLoading] = useState(true);
const [snapshotError, setSnapshotError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [sendBusy, setSendBusy] = useState(false);
const lastSeqRef = useRef(0);
lastSeqRef.current = state.lastSeq;
const runId = state.run?.id ?? state.session?.active_run_id ?? null;
// ── 事件合帧:高频 delta 先缓冲,统一一帧进 reducer ─────────────────────
const pendingEventsRef = useRef<CollaborationEvent[]>([]);
const flushTimerRef = useRef<number | undefined>(undefined);
const flushEvents = useCallback(() => {
flushTimerRef.current = undefined;
if (pendingEventsRef.current.length === 0) return;
const batch = pendingEventsRef.current;
pendingEventsRef.current = [];
dispatch({ type: "events", events: batch });
}, []);
const handleRawEvent = useCallback(
(raw: unknown) => {
const event = normalizeCollaborationEvent(raw);
if (!event) return;
pendingEventsRef.current.push(event);
if (flushTimerRef.current === undefined) {
flushTimerRef.current = window.setTimeout(flushEvents, EVENT_FLUSH_MS);
}
},
[flushEvents],
);
useEffect(() => {
return () => {
if (flushTimerRef.current !== undefined) window.clearTimeout(flushTimerRef.current);
};
}, []);
// ── 快照加载 ─────────────────────────────────────────────────────────────
const [snapshotTick, setSnapshotTick] = useState(0);
const loadSnapshot = useCallback(
async (quiet: boolean) => {
if (!quiet) setSnapshotLoading(true);
try {
const snapshot = await driver.getSnapshot(sessionId);
if (!snapshot) {
setSnapshotError("会话不存在或已删除");
return;
}
dispatch({ type: "snapshot", snapshot });
setSnapshotError(null);
} catch (err) {
setSnapshotError(err instanceof Error ? err.message : String(err));
} finally {
if (!quiet) setSnapshotLoading(false);
}
},
[driver, sessionId],
);
useEffect(() => {
let cancelled = false;
setSnapshotLoading(true);
void driver
.getSnapshot(sessionId)
.then((snapshot) => {
if (cancelled) return;
if (!snapshot) {
setSnapshotError("会话不存在或已删除");
return;
}
dispatch({ type: "snapshot", snapshot });
setSnapshotError(null);
})
.catch((err: unknown) => {
if (!cancelled) setSnapshotError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setSnapshotLoading(false);
});
return () => {
cancelled = true;
};
}, [driver, sessionId, snapshotTick]);
// ── SSE:run 存在且未终态时连接 ────────────────────────────────────────
const runTerminal =
state.run?.status === "completed" ||
state.run?.status === "failed" ||
state.run?.status === "cancelled";
const streamEnabled = Boolean(state.snapshotLoaded && runId && !runTerminal);
useReportCollaborationStream({
sessionId,
runId,
enabled: streamEnabled,
afterSeqRef: lastSeqRef,
onEvent: handleRawEvent,
onStatusChange: setStreamStatus,
});
// 终态:最后一次 REST 补拉,确保正式版本/收尾消息完整后再停流。
const finalizeRef = useRef<string | null>(null);
useEffect(() => {
if (!runTerminal || !state.run) return;
const activeRunId = state.run.id;
if (finalizeRef.current === activeRunId) return;
finalizeRef.current = activeRunId;
void driver
.listRunEvents(activeRunId, lastSeqRef.current)
.then((events) => {
if (events.length > 0) dispatch({ type: "events", events });
})
.catch(() => undefined);
setStreamStatus({ status: "closed", attempts: 0 });
}, [driver, runTerminal, state.run]);
// 序号缺口:暂停 live 应用,REST 补拉后由 reducer drain 缓冲。
const gap = hasSeqGap(state);
const backfillingRef = useRef(false);
useEffect(() => {
if (!gap || !runId || backfillingRef.current) return;
backfillingRef.current = true;
void driver
.listRunEvents(runId, lastSeqRef.current)
.then((events) => {
if (events.length > 0) dispatch({ type: "events", events });
})
.catch(() => undefined)
.finally(() => {
backfillingRef.current = false;
});
}, [driver, gap, runId]);
// 无 run 的前置阶段(澄清/规划):后端事件只在 run 流上,轮询快照兜底。
const preRunPolling =
state.snapshotLoaded &&
!state.run &&
(state.session?.status === "clarifying" ||
state.session?.status === "planning" ||
state.session?.status === "proposal_ready");
useEffect(() => {
if (!preRunPolling) return;
const timer = window.setInterval(() => {
void loadSnapshot(true);
}, PRE_RUN_POLL_MS);
return () => window.clearInterval(timer);
}, [loadSnapshot, preRunPolling]);
// ── 动作 ────────────────────────────────────────────────────────────────
const send = useCallback(
async (text: string, target?: RcInterventionTarget) => {
const trimmed = text.trim();
if (!trimmed) return;
setSendBusy(true);
setActionError(null);
const idempotencyKey = newIdempotencyKey();
// 乐观用户消息:服务器镜像携带同 idempotency_key 时原地替换。
dispatch({ type: "optimistic", message: optimisticUserMessage(trimmed, idempotencyKey) });
try {
// 命令只存在于 run 执行阶段(running / awaiting_input / reviewing);
// 澄清、规划、完成后的问答走 session messages。
const sessionStatus = state.session?.status;
const commandPhase =
sessionStatus === "running" ||
sessionStatus === "awaiting_input" ||
sessionStatus === "reviewing";
const activeRun = commandPhase ? state.run : null;
if (activeRun && isRunActive(activeRun.status)) {
await driver.createCommand(
activeRun.id,
{
text: trimmed,
target_node_id: target?.nodeId ?? null,
agent_run_id: target?.agentRunId ?? null,
},
idempotencyKey,
);
} else {
await driver.sendMessage(
sessionId,
{
text: trimmed,
target_node_id: target?.nodeId ?? null,
agent_run_id: target?.agentRunId ?? null,
},
idempotencyKey,
);
}
// POST /messages 只回用户消息;协调者澄清/就绪回复写在同一次请求里,
// 必须立刻拉快照,否则本地 status 仍是 empty,轮询不会启动、AI 气泡不出现。
await loadSnapshot(true);
} catch (err) {
// 失败保留乐观消息与输入,提供重试入口(不回滚 UI)。
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSendBusy(false);
}
},
[driver, loadSnapshot, sessionId, state.run, state.session?.status],
);
const requestPlans = useCallback(async () => {
setActionError(null);
try {
await driver.requestPlans(sessionId, state.session?.requirement_revision ?? null);
await loadSnapshot(true);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
}, [driver, loadSnapshot, sessionId, state.session]);
const selectPlan = useCallback(
async (planId: string) => {
setActionError(null);
try {
await driver.selectPlan(sessionId, planId, state.session?.requirement_revision ?? null);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
// 409:方案 revision 过期,刷新快照让用户基于最新方案重选。
setSnapshotTick((tick) => tick + 1);
}
},
[driver, sessionId, state.session],
);
const startRun = useCallback(
async (planId: string) => {
setActionError(null);
try {
await driver.startRun(
sessionId,
{ plan_id: planId },
state.session?.requirement_revision ?? null,
);
// 立即刷新快照拿到 active_run_id,尽早接上 SSE。
setSnapshotTick((tick) => tick + 1);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
setSnapshotTick((tick) => tick + 1);
}
},
[driver, sessionId, state.session],
);
const cancelRun = useCallback(async () => {
const activeRunId = state.run?.id;
if (!activeRunId) return;
setActionError(null);
try {
await driver.cancelRun(activeRunId);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
}, [driver, state.run]);
const confirmCommand = useCallback(
async (commandId: string) => {
const activeRunId = state.run?.id;
if (!activeRunId) return;
setActionError(null);
try {
await driver.confirmCommand(activeRunId, commandId);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
},
[driver, state.run],
);
const cancelCommand = useCallback(
async (commandId: string) => {
const activeRunId = state.run?.id;
if (!activeRunId) return;
setActionError(null);
try {
await driver.cancelCommand(activeRunId, commandId);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
},
[driver, state.run],
);
return {
state,
streamStatus,
snapshotLoading,
snapshotError,
actionError,
clearActionError: () => setActionError(null),
sendBusy,
send,
requestPlans,
selectPlan,
startRun,
cancelRun,
confirmCommand,
cancelCommand,
refreshSnapshot: () => setSnapshotTick((tick) => tick + 1),
};
}
@@ -0,0 +1,110 @@
/**
* Run SSE 连接管理:断线指数退避重连、Last-Event-ID/after_seq 续接。
*
* 连接生命周期由 [sessionId, runId, enabled] 驱动;lastSeq 通过 ref 传入,
* 避免每次事件应用都重建连接。事件以回调上抛,由会话 hook 合帧后进 reducer。
*/
import { useEffect, useRef } from "react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { CollaborationEvent } from "@/report-collaboration/api/types";
export type RcConnectionStatus =
| "idle"
| "connecting"
| "live"
| "reconnecting"
| "closed"
| "error";
export interface RcStreamStatus {
status: RcConnectionStatus;
attempts: number;
message?: string;
}
export interface UseReportCollaborationStreamOptions {
sessionId: string | null;
runId: string | null;
enabled: boolean;
/** 最新已应用 seq 的 ref(重连时作为 after_seq)。 */
afterSeqRef: React.MutableRefObject<number>;
onEvent: (event: CollaborationEvent) => void;
onStatusChange?: (status: RcStreamStatus) => void;
}
const BACKOFF_BASE_MS = 1000;
const BACKOFF_MAX_MS = 30_000;
export function useReportCollaborationStream({
sessionId,
runId,
enabled,
afterSeqRef,
onEvent,
onStatusChange,
}: UseReportCollaborationStreamOptions): void {
const onEventRef = useRef(onEvent);
const onStatusRef = useRef(onStatusChange);
onEventRef.current = onEvent;
onStatusRef.current = onStatusChange;
useEffect(() => {
if (!sessionId || !runId || !enabled) {
onStatusRef.current?.({ status: "idle", attempts: 0 });
return;
}
const driver = getReportCollaborationDriver();
let disposed = false;
let closeStream: (() => void) | null = null;
let attempts = 0;
let reconnectTimer: number | undefined;
const setStatus = (status: RcConnectionStatus, message?: string) => {
if (!disposed) {
onStatusRef.current?.({ status, attempts, message });
}
};
const connect = () => {
if (disposed) return;
setStatus(attempts === 0 ? "connecting" : "reconnecting");
closeStream = driver.openRunStream(runId, afterSeqRef.current, {
onEvent: (event) => {
if (!disposed) onEventRef.current(event);
},
onOpen: () => {
attempts = 0;
setStatus("live");
},
onError: (message) => {
if (disposed) return;
setStatus("reconnecting", message);
scheduleReconnect();
},
onClose: () => {
// 服务端正常关流(终态)。终态判定与最终补拉由会话 hook 负责,
// 这里标记 closed、不再自动重连,避免终态重连风暴。
if (!disposed) setStatus("closed");
},
});
};
const scheduleReconnect = () => {
if (disposed) return;
attempts += 1;
const delay = Math.min(BACKOFF_BASE_MS * 2 ** (attempts - 1), BACKOFF_MAX_MS);
reconnectTimer = window.setTimeout(connect, delay);
};
connect();
return () => {
disposed = true;
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
closeStream?.();
};
}, [sessionId, runId, enabled, afterSeqRef]);
}
@@ -0,0 +1,119 @@
/**
* 两栏布局状态:左对话宽度(持久化到 localStorage,仅浏览器展示偏好)、
* 窄屏单面板切换、「专注对话/专注画布」模式。
*/
import { useCallback, useEffect, useRef, useState } from "react";
const WIDTH_STORAGE_KEY = "deerflow.rc-conversation-width";
export const PANEL_MIN_WIDTH = 360;
export const PANEL_MAX_WIDTH = 720;
export const PANEL_DEFAULT_WIDTH = 480;
export const CANVAS_MIN_WIDTH = 480;
/** 低于该宽度切换为单面板(顶部按钮切换对话/画布)。 */
export const NARROW_BREAKPOINT = 960;
export const RESIZE_KEYBOARD_STEP = 16;
export type NarrowView = "conversation" | "canvas";
export type FocusMode = "both" | "conversation" | "canvas";
function loadStoredWidth(): number | null {
try {
const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY);
const value = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
export interface UseReportPanelLayoutResult {
conversationWidth: number;
setConversationWidth: (width: number) => void;
nudgeConversationWidth: (delta: number) => void;
clampConversationWidth: (width: number) => number;
isNarrow: boolean;
narrowView: NarrowView;
setNarrowView: (view: NarrowView) => void;
focusMode: FocusMode;
setFocusMode: (mode: FocusMode) => void;
/** 布局主体容器 ref(用于按容器实际宽度计算拖拽上限)。 */
bodyRef: React.RefObject<HTMLDivElement | null>;
}
export function useReportPanelLayout(): UseReportPanelLayoutResult {
const [conversationWidth, setWidth] = useState<number>(() => {
const stored = typeof window === "undefined" ? null : loadStoredWidth();
return stored !== null
? Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, stored))
: PANEL_DEFAULT_WIDTH;
});
const [isNarrow, setIsNarrow] = useState(
() => typeof window !== "undefined" && window.innerWidth < NARROW_BREAKPOINT,
);
const [narrowView, setNarrowView] = useState<NarrowView>("conversation");
const [focusMode, setFocusMode] = useState<FocusMode>("both");
const bodyRef = useRef<HTMLDivElement | null>(null);
// 窄屏判定 + 宽度上限随容器收缩(画布保底 480px)。
useEffect(() => {
const evaluate = () => {
setIsNarrow(window.innerWidth < NARROW_BREAKPOINT);
const bodyWidth = bodyRef.current?.clientWidth ?? 0;
if (bodyWidth > 0) {
setWidth((current) => clampWidth(current, bodyWidth));
}
};
evaluate();
window.addEventListener("resize", evaluate);
return () => window.removeEventListener("resize", evaluate);
}, []);
const clampConversationWidth = useCallback((width: number) => {
const bodyWidth = bodyRef.current?.clientWidth ?? 0;
return clampWidth(width, bodyWidth);
}, []);
const setConversationWidth = useCallback(
(width: number) => {
setWidth(clampConversationWidth(width));
},
[clampConversationWidth],
);
const nudgeConversationWidth = useCallback(
(delta: number) => {
setWidth((current) => clampConversationWidth(current + delta));
},
[clampConversationWidth],
);
useEffect(() => {
try {
window.localStorage.setItem(WIDTH_STORAGE_KEY, String(conversationWidth));
} catch {
// 忽略存储失败(隐私模式等)。
}
}, [conversationWidth]);
return {
conversationWidth,
setConversationWidth,
nudgeConversationWidth,
clampConversationWidth,
isNarrow,
narrowView,
setNarrowView,
focusMode,
setFocusMode,
bodyRef,
};
}
function clampWidth(width: number, bodyWidth: number): number {
const dynamicMax =
bodyWidth > 0 ? Math.min(PANEL_MAX_WIDTH, bodyWidth - CANVAS_MIN_WIDTH) : PANEL_MAX_WIDTH;
const max = Math.max(PANEL_MIN_WIDTH, dynamicMax);
return Math.round(Math.min(max, Math.max(PANEL_MIN_WIDTH, width)));
}
@@ -0,0 +1,42 @@
/**
* 方案选择展示状态:预览(纯前端)与已选定(服务器事实)分离。
* 点击卡片只改 previewPlanId;「采用此方案」才触发 select API。
*/
import { useCallback, useEffect, useState } from "react";
import type { ReportPlanCandidate } from "@/report-collaboration/api/types";
export interface UseReportPlanSelectionResult {
previewPlanId: string | null;
/** 点击卡片/查看流程:仅切换预览,不触发任何服务器变更。 */
previewPlan: (planId: string) => void;
/** 当前画布应渲染的方案(预览 > 已选定 > 最新提案)。 */
effectivePlanId: string | null;
}
export function useReportPlanSelection(
plans: ReportPlanCandidate[],
selectedPlanId: string | null,
): UseReportPlanSelectionResult {
const [previewPlanId, setPreviewPlanId] = useState<string | null>(null);
// 方案组更新(重新生成 / 新 revision)后清理失效预览。
useEffect(() => {
if (previewPlanId && plans.length > 0 && !plans.some((plan) => plan.id === previewPlanId)) {
setPreviewPlanId(null);
}
}, [plans, previewPlanId]);
const previewPlan = useCallback((planId: string) => {
setPreviewPlanId(planId);
}, []);
const effectivePlanId =
(previewPlanId && plans.some((plan) => plan.id === previewPlanId) ? previewPlanId : null) ??
selectedPlanId ??
plans[0]?.id ??
null;
return { previewPlanId, previewPlan, effectivePlanId };
}
@@ -0,0 +1,652 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import type { Message } from "@langchain/langgraph-sdk";
import { toast } from "sonner";
import { FileText, Loader2, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import { AgentStageSelector } from "@/report-collaboration/components/AgentStageSelector";
import { ConnectionStatusBar } from "@/report-collaboration/components/ConnectionStatusBar";
import { InterventionCard } from "@/report-collaboration/components/InterventionCard";
import { NodeInspectorDrawer } from "@/report-collaboration/components/NodeInspectorDrawer";
import { ReportCollaborationHeader } from "@/report-collaboration/components/ReportCollaborationHeader";
import { ReportCollaborationLayout, NarrowSwitch } from "@/report-collaboration/components/ReportCollaborationLayout";
import { ReportComposer, type ComposerTarget } from "@/report-collaboration/components/ReportComposer";
import { ReportConversationPanel } from "@/report-collaboration/components/ReportConversationPanel";
import { ReportFlowCanvas } from "@/report-collaboration/components/ReportFlowCanvas";
import { ReportPlanCards } from "@/report-collaboration/components/ReportPlanCards";
import {
ReportAvailableCard,
ReportPreviewDialog,
} from "@/report-collaboration/components/ReportPreviewDialog";
import { ReportSandboxOverlay } from "@/report-collaboration/components/ReportSandboxOverlay";
import { ReportSpeakerShell } from "@/report-collaboration/components/ReportSpeakerShell";
import { SessionHistoryPanel } from "@/report-collaboration/components/SessionHistoryPanel";
import { useReportPanelLayout } from "@/report-collaboration/hooks/useReportPanelLayout";
import { useReportCollaborationMessages } from "@/report-collaboration/hooks/useReportCollaborationMessages";
import { useReportCollaborationSession, isRunActive } from "@/report-collaboration/hooks/useReportCollaborationSession";
import { useReportPlanSelection } from "@/report-collaboration/hooks/useReportPlanSelection";
import {
buildCommandConfirmCard,
buildPlanGroupCard,
buildReportAvailableCard,
readReportCollaborationCard,
} from "@/report-collaboration/state/message-adapter";
import { resolveReportSpeaker } from "@/report-collaboration/state/speaker";
import {
derivePagePhase,
selectAgentRunForNode,
selectAgentTabs,
selectAwaitingInputNodes,
selectHeadReportVersion,
selectNodeStatusMap,
selectPendingConfirmationCommands,
selectPlanForCanvas,
selectPreferredParallelFocus,
selectStreamingAgentRunIds,
} from "@/report-collaboration/state/selectors";
import { buildBaseStreamFacade } from "@/report-collaboration/state/message-adapter";
const NEW_SESSION_THREAD = buildBaseStreamFacade([], false);
/** 左侧 Tab:对话 / 历史。 */
function ConversationSidebarTabs({
conversation,
history,
}: {
conversation: React.ReactNode;
history: React.ReactNode;
}) {
const [tab, setTab] = useState<"conversation" | "history">("conversation");
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="rc-tabs" role="tablist" aria-label="左侧面板">
<button type="button" role="tab" className="rc-tab" data-active={tab === "conversation"} onClick={() => setTab("conversation")}>
对话
</button>
<button type="button" role="tab" className="rc-tab" data-active={tab === "history"} onClick={() => setTab("history")}>
历史
</button>
</div>
{tab === "conversation" ? conversation : history}
</div>
);
}
/**
* 新会话入口页(无 sessionId):空态 + 输入需求后创建会话并跳转,
* 首条消息通过 location.state 传递,由会话页发送(避免竞态)。
*/
export default function ReportCollaborationPage() {
const navigate = useNavigate();
const layout = useReportPanelLayout();
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
const start = useCallback(
async (text: string) => {
setStarting(true);
setError(null);
try {
const created = await getReportCollaborationDriver().createSession();
navigate(`/page/workspace/report-collaboration/${created.id}`, {
state: { rcInitialMessage: text },
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setStarting(false);
}
},
[navigate],
);
return (
<ArtifactsProvider>
<ReportCollaborationLayout
layout={layout}
header={
<ReportCollaborationHeader
title="新会话"
phase="empty"
onBack={() => navigate("/page/workspace/chats/new")}
onNewSession={() => navigate("/page/workspace/report-collaboration")}
onOpenReport={() => undefined}
hasReport={false}
running={false}
onCancelRun={() => undefined}
/>
}
conversation={
<ReportConversationPanel
sessionId="rc-new"
thread={NEW_SESSION_THREAD}
businessCardSlot={() => null}
loading={false}
emptyHint={
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-10 opacity-40" style={{ color: "var(--rc-accent)" }} aria-hidden />
<p className="text-[14px] font-semibold">描述你的报告需求</p>
<p className="max-w-72 text-[12px] leading-5">
系统会先澄清关键信息,然后给出 2~3 个候选协作方案;
确认方案后多智能体团队开始检索、核验、分析与写作,全过程可在对话中干预。
</p>
{error ? (
<p className="text-[12px]" style={{ color: "var(--rc-danger)" }}>
{error}
</p>
) : null}
</div>
}
composer={
<ReportComposer
phase="empty"
busy={starting}
target={null}
onClearTarget={() => undefined}
onSend={(text) => void start(text)}
focusSignal={0}
/>
}
/>
}
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
/>
</ArtifactsProvider>
);
}
/** 会话页:左对话(复用 MessageList)/ 右画布 / 节点抽屉 / 报告预览。 */
export function ReportCollaborationSessionPage({ sessionId }: { sessionId: string }) {
const navigate = useNavigate();
const location = useLocation();
const session = useReportCollaborationSession(sessionId);
const { state } = session;
const layout = useReportPanelLayout();
const phase = useMemo(() => derivePagePhase(state), [state]);
const agentTabs = useMemo(() => selectAgentTabs(state), [state]);
const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null);
const [focusPinned, setFocusPinned] = useState(false);
const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]);
const pinnedAgent =
focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)
? (state.agentRuns[focusedAgentRunId] ?? null)
: null;
const effectiveFocus =
focusPinned && focusedAgentRunId && state.agentRuns[focusedAgentRunId]
? focusedAgentRunId
: agentTabs.length === 0
? null
: focusedAgentRunId && tabIds.has(focusedAgentRunId)
? focusedAgentRunId
: agentTabs[0].agent_run_id;
const focusedTab =
agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ??
(effectiveFocus ? state.agentRuns[effectiveFocus] ?? null : null);
const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection(
state.plans,
state.selectedPlanId,
);
const canvasPlan = useMemo(
() => selectPlanForCanvas(state, previewPlanId),
// eslint-disable-next-line react-hooks/exhaustive-deps
[state.plans, state.planMap, state.selectedPlanId, previewPlanId],
);
void effectivePlanId;
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [composerTarget, setComposerTarget] = useState<ComposerTarget | null>(null);
const [focusSignal, setFocusSignal] = useState(0);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewVersionId, setPreviewVersionId] = useState<string | null>(null);
const [restoreBusy, setRestoreBusy] = useState(false);
// 新会话首条消息(location.state.rcInitialMessage),快照就绪且时间线为空时发送一次。
const initialSentRef = useRef(false);
useEffect(() => {
if (initialSentRef.current || !state.snapshotLoaded) return;
initialSentRef.current = true;
const initial = (location.state as { rcInitialMessage?: unknown } | null)?.rcInitialMessage;
if (typeof initial === "string" && initial.trim() && state.messageOrder.length === 0) {
void session.send(initial);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.snapshotLoaded]);
// 动作错误 → toast。
useEffect(() => {
if (session.actionError) {
toast.error(session.actionError, { description: "操作未生效,可重试或刷新快照" });
session.clearActionError();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.actionError]);
// 执行波次:默认落到正在输出的成员;点头像/画布后保持该成员直到该波次结束。
// 全部结束后若用户钉住某个节点,保留回看,不强制清回全量时间线。
useEffect(() => {
if (agentTabs.length === 0) {
if (!focusPinned && focusedAgentRunId) setFocusedAgentRunId(null);
return;
}
if (focusPinned && focusedAgentRunId && tabIds.has(focusedAgentRunId)) return;
if (focusPinned && focusedAgentRunId && !tabIds.has(focusedAgentRunId)) {
setFocusPinned(false);
}
const next = selectPreferredParallelFocus(agentTabs);
if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
const runStarted = Boolean(state.run && state.run.plan_id);
const extraCards = useMemo<Message[]>(() => {
const cards: Message[] = [];
if (
state.plans.length > 0 &&
!runStarted &&
phase === "proposal_ready"
) {
cards.push(
buildPlanGroupCard(state.plans, state.selectedPlanId, state.session?.requirement_revision ?? 1),
);
}
for (const command of selectPendingConfirmationCommands(state)) {
cards.push(buildCommandConfirmCard(command));
}
const head = selectHeadReportVersion(state);
if (head && (phase === "completed" || phase === "reviewing" || phase === "cancelled")) {
cards.push(buildReportAvailableCard(head, state.reportVersions.length));
}
return cards;
}, [phase, runStarted, state]);
const { displayThread } = useReportCollaborationMessages(
state,
effectiveFocus,
extraCards,
session.sendBusy,
);
const streamingIds = useMemo(() => selectStreamingAgentRunIds(state), [state]);
const awaitingIds = useMemo(() => {
const ids = new Set<string>();
for (const node of selectAwaitingInputNodes(state)) {
for (const agentRun of Object.values(state.agentRuns)) {
if (agentRun.node_run_id?.startsWith(`${node.node_id}-`)) {
ids.add(agentRun.agent_run_id);
}
}
}
return ids;
}, [state]);
const findMemberOfNode = useCallback(
(nodeId: string) => selectAgentRunForNode(state, nodeId),
[state],
);
const handleSelectNode = useCallback(
(nodeId: string | null) => {
setSelectedNodeId(nodeId);
setDrawerOpen(Boolean(nodeId));
if (nodeId) {
const member = findMemberOfNode(nodeId);
if (member) {
setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true);
}
}
},
[findMemberOfNode],
);
const handleNodeComment = useCallback(
(nodeId: string, nodeName: string) => {
const member = findMemberOfNode(nodeId);
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
if (member) {
setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true);
}
setDrawerOpen(false);
setFocusSignal((signal) => signal + 1);
},
[findMemberOfNode],
);
const handleRestore = useCallback(
async (versionId: string) => {
setRestoreBusy(true);
try {
await getReportCollaborationDriver().restoreReportVersion(sessionId, versionId);
session.refreshSnapshot();
toast.success("已恢复为新的头版本");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setRestoreBusy(false);
}
},
[sessionId, session],
);
// 澄清完成(协调者已写入需求版本)但还没生成过方案:给出显式「发起规划」入口。
const requirementReady = useMemo(
() =>
Object.values(state.messagesById).some(
(message) =>
message.role === "ai" &&
(message.metadata as Record<string, unknown> | null | undefined)?.ready_to_plan === true,
),
[state.messagesById],
);
const showPlanRequestEntry =
state.session?.status === "clarifying" && state.plans.length === 0 && requirementReady;
const [planRequestBusy, setPlanRequestBusy] = useState(false);
const handleRequestPlans = useCallback(async () => {
setPlanRequestBusy(true);
try {
await getReportCollaborationDriver().requestPlans(
sessionId,
state.session?.requirement_revision ?? null,
);
await session.refreshSnapshot();
toast.success("已生成候选方案,可在对话中选择");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setPlanRequestBusy(false);
}
}, [sessionId, session, state.session?.requirement_revision]);
const businessCardSlot = useCallback(
(message: Message) => {
const card = readReportCollaborationCard(message);
if (!card) return null;
if (card.kind === "plan_group") {
return (
<ReportPlanCards
plans={card.plans}
selectedPlanId={card.selectedPlanId}
previewPlanId={previewPlanId}
onPreview={previewPlan}
onSelect={(planId) => void session.selectPlan(planId)}
onStart={(planId) => void session.startRun(planId)}
onRegenerate={() => void session.requestPlans()}
selectBusy={session.sendBusy}
startBusy={session.sendBusy}
regenerateBusy={false}
/>
);
}
if (card.kind === "command_confirm") {
return (
<InterventionCard
command={card.command}
busy={session.sendBusy}
onConfirm={(commandId) => void session.confirmCommand(commandId)}
onCancel={(commandId) => void session.cancelCommand(commandId)}
/>
);
}
if (card.kind === "report_available") {
return (
<ReportAvailableCard
version={card.version}
versionCount={card.versionCount}
onOpen={(versionId) => {
setPreviewVersionId(versionId);
setPreviewOpen(true);
}}
/>
);
}
return null;
},
[previewPlan, previewPlanId, session],
);
const speakerShell = useCallback(
(messages: Message[], children: React.ReactNode) => {
const speaker = resolveReportSpeaker(messages, state.agentRuns);
if (!speaker) return children;
return (
<ReportSpeakerShell
speaker={speaker}
avatarSelected={Boolean(speaker.agentRunId) && effectiveFocus === speaker.agentRunId}
onAvatarClick={(agentRunId) => {
if (focusPinned && effectiveFocus === agentRunId) {
setFocusedAgentRunId(null);
setFocusPinned(false);
return;
}
setFocusedAgentRunId(agentRunId);
setFocusPinned(true);
}}
>
{children}
</ReportSpeakerShell>
);
},
[effectiveFocus, focusPinned, state.agentRuns],
);
const runActive = isRunActive(state.run?.status);
const headVersion = selectHeadReportVersion(state);
const nodeStates = selectNodeStatusMap(state);
const selectedPlanNode = canvasPlan?.nodes.find((node) => node.id === selectedNodeId) ?? null;
const outgoingIds = new Set(canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? []);
const drawerFallback = selectedNodeId
? {
label: state.nodes[selectedNodeId]?.label ?? selectedPlanNode?.label ?? selectedNodeId,
status: state.nodes[selectedNodeId]?.status ?? "planned",
role: state.nodes[selectedNodeId]?.role_display_name ?? selectedPlanNode?.role_display_name ?? null,
angle: state.nodes[selectedNodeId]?.angle ?? selectedPlanNode?.angle ?? null,
kind: (selectedPlanNode && selectedPlanNode.depends_on.length === 0
? "start"
: outgoingIds.size > 0
? "agent"
: "end") as "start" | "agent" | "end",
mission: selectedPlanNode?.mission ?? null,
input_artifact_types: selectedPlanNode?.input_artifact_types ?? [],
output_artifact_type: selectedPlanNode?.output_artifact_type ?? null,
allowed_tools: selectedPlanNode?.allowed_tools ?? [],
acceptance_criteria: selectedPlanNode?.acceptance_criteria ?? [],
upstream: selectedPlanNode?.depends_on ?? [],
downstream: canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? [],
}
: null;
if (session.snapshotError && !state.snapshotLoaded) {
return (
<ReportCollaborationLayout
layout={layout}
header={
<ReportCollaborationHeader
title="会话不可用"
phase="empty"
onBack={() => navigate("/page/workspace/report-collaboration")}
onNewSession={() => navigate("/page/workspace/report-collaboration")}
onOpenReport={() => undefined}
hasReport={false}
running={false}
onCancelRun={() => undefined}
/>
}
conversation={
<div className="rc-empty">
<FileText className="size-8 opacity-30" aria-hidden />
<p>{session.snapshotError}</p>
<button type="button" className="rc-btn" onClick={() => navigate("/page/workspace/report-collaboration")}>
返回新建会话
</button>
</div>
}
canvas={<ReportFlowCanvas plan={null} nodeStates={{}} selectedNodeId={null} onSelectNode={() => undefined} />}
statusBar={<ConnectionStatusBar status="idle" runId={null} lastSeq={0} />}
/>
);
}
return (
<SubtasksProvider>
<ArtifactsProvider>
<ThreadContext.Provider value={{ thread: displayThread, isMock: true }}>
<ReportCollaborationLayout
layout={layout}
header={
<ReportCollaborationHeader
title={state.session?.title ?? "报告协作"}
phase={phase}
onBack={() => navigate("/page/workspace/chats/new")}
onNewSession={() => navigate("/page/workspace/report-collaboration")}
onOpenReport={() => {
if (headVersion) {
setPreviewVersionId(headVersion.id);
setPreviewOpen(true);
}
}}
hasReport={Boolean(headVersion)}
running={runActive}
onCancelRun={() => void session.cancelRun()}
extra={
layout.isNarrow ? (
<NarrowSwitch narrowView={layout.narrowView} setNarrowView={layout.setNarrowView} />
) : null
}
/>
}
conversation={
<ConversationSidebarTabs
conversation={
<ReportConversationPanel
sessionId={sessionId}
thread={displayThread}
businessCardSlot={businessCardSlot}
speakerShell={speakerShell}
listKey={effectiveFocus ?? "timeline"}
loading={session.snapshotLoading}
stageSelector={
<AgentStageSelector
tabs={agentTabs}
pinnedAgent={pinnedAgent}
phaseLabel={
agentTabs.length > 1
? `并行 ${agentTabs.length} 席`
: focusedTab?.display_name ?? (agentTabs[0]?.display_name ?? null)
}
focusedAgentRunId={effectiveFocus}
onFocus={(agentRunId) => {
setFocusedAgentRunId(agentRunId);
setFocusPinned(true);
}}
onShowAll={() => {
setFocusedAgentRunId(null);
setFocusPinned(false);
}}
streamingAgentRunIds={streamingIds}
awaitingAgentRunIds={awaitingIds}
/>
}
emptyHint={
effectiveFocus
? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里`
: undefined
}
composer={
<div className="flex flex-col">
{showPlanRequestEntry ? (
<div className="mb-1.5 flex justify-center">
<button
type="button"
className="rc-btn"
style={{
background: "var(--rc-accent)",
color: "#fff",
borderColor: "var(--rc-accent)",
}}
disabled={planRequestBusy}
onClick={() => void handleRequestPlans()}
>
{planRequestBusy ? (
<Loader2 className="rc-spin size-3.5" aria-hidden />
) : (
<Sparkles className="size-3.5" aria-hidden />
)}
{planRequestBusy ? "正在生成候选方案…" : "需求已就绪,生成候选方案"}
</button>
</div>
) : null}
<ReportComposer
phase={phase}
busy={session.sendBusy}
target={composerTarget}
onClearTarget={() => setComposerTarget(null)}
onSend={(text) =>
void session.send(text, {
nodeId: composerTarget?.nodeId ?? null,
agentRunId: composerTarget?.agentRunId ?? null,
})
}
focusSignal={focusSignal}
/>
</div>
}
/>
}
history={<SessionHistoryPanel activeSessionId={sessionId} />}
/>
}
canvas={
<>
<ReportFlowCanvas
plan={canvasPlan}
nodeStates={nodeStates}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
<NodeInspectorDrawer
open={drawerOpen}
nodeId={selectedNodeId}
runId={state.run?.id ?? state.session?.active_run_id ?? null}
fallback={drawerFallback}
onClose={() => setDrawerOpen(false)}
onComment={handleNodeComment}
/>
<ReportSandboxOverlay threadId={sessionId} />
</>
}
statusBar={
<ConnectionStatusBar
status={session.streamStatus.status}
runId={state.run?.id ?? null}
lastSeq={state.lastSeq}
message={session.streamStatus.message}
/>
}
/>
<ReportPreviewDialog
open={previewOpen}
onClose={() => setPreviewOpen(false)}
versions={state.reportVersions}
activeVersionId={previewVersionId}
onSelectVersion={setPreviewVersionId}
onRestore={(versionId) => void handleRestore(versionId)}
restoreBusy={restoreBusy}
/>
</ThreadContext.Provider>
</ArtifactsProvider>
</SubtasksProvider>
);
}
@@ -0,0 +1,85 @@
/**
* SSE / REST 事件包的防御性归一化: malformed 帧静默丢弃,不进入 reducer。
*/
import type { CollaborationEvent, CollaborationEventType } from "@/report-collaboration/api/types";
const KNOWN_EVENT_TYPES = new Set<string>([
"session.snapshot",
"message.created",
"message.delta",
"message.completed",
"tool_call.created",
"tool_call.delta",
"tool_call.completed",
"tool_result.created",
"tool_result.delta",
"tool_result.completed",
"team_message.created",
"team_message.delta",
"team_message.completed",
"requirement.updated",
"clarification.requested",
"clarification.resolved",
"plan.progress",
"plan.proposed",
"plan.selected",
"run.status.changed",
"node.created",
"node.status.changed",
"node.progress",
"node.attempt.created",
"artifact.created",
"artifact.validated",
"artifact.rejected",
"source.added",
"command.classified",
"command.confirmation.required",
"command.accepted",
"command.completed",
"command.failed",
"command.cancelled",
"report.delta",
"report.version.created",
"review.created",
"error",
"heartbeat",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* 校验并归一化一个事件包。返回 null 表示丢弃(结构非法)。
* 未知 `type` 保留透传(前向兼容:新事件类型不应导致旧前端崩溃),
* 但 seq/sessionId 必须合法。
*/
export function normalizeCollaborationEvent(raw: unknown): CollaborationEvent | null {
if (!isRecord(raw)) return null;
const eventId = raw.eventId ?? raw.event_id;
const sessionId = raw.sessionId ?? raw.session_id;
const runId = raw.runId ?? raw.run_id;
const seq = raw.seq;
const type = raw.type;
const timestamp = raw.timestamp;
if (typeof eventId !== "string" || eventId === "") return null;
if (typeof sessionId !== "string" || sessionId === "") return null;
if (typeof seq !== "number" || !Number.isFinite(seq) || seq < 0) return null;
if (typeof type !== "string" || type === "") return null;
return {
eventId,
sessionId,
runId: typeof runId === "string" ? runId : null,
seq,
type: (KNOWN_EVENT_TYPES.has(type) ? type : type) as CollaborationEventType,
timestamp: typeof timestamp === "string" ? timestamp : new Date().toISOString(),
data: raw.data ?? {},
};
}
/** 是否为已知业务事件类型(用于状态栏与测试)。 */
export function isKnownEventType(type: string): boolean {
return KNOWN_EVENT_TYPES.has(type);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,273 @@
/**
* 无损协议适配:CollaborationMessage → LangGraph SDK Message + BaseStream 外观。
*
* 规则(FE-5):
* - 一对一映射,不概括文本、不吞工具结果。
* - 工具调用挂在所属 ai 消息的 `tool_calls`;返回为 `tool` 消息,`tool_call_id`
* 严格配对(现有 MessageGroup 直接渲染)。
* - AgentScope 归属写入 `additional_kwargs.report_collaboration`。
* - 业务卡(方案组 / 命令确认 / 报告入口)为**前端派生的虚拟 ai 消息**,经
* MessageList 的通用业务卡插槽(`additional_kwargs.report_collaboration_card`)
* 渲染;未传插槽的页面零渲染。
*/
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "@/core/threads";
import type {
AgentRun,
CollaborationCommand,
CollaborationMessage,
ReportPlanCandidate,
ReportVersion,
} from "@/report-collaboration/api/types";
/** 报告在沙箱中的虚拟路径,对齐问答页 write_file / 深度研究 stub。 */
export const REPORT_OUTPUT_PATH = "/mnt/user-data/outputs/report.md";
// ── 归属标记 ─────────────────────────────────────────────────────────────────
export interface ReportCollaborationKwargs {
agentRunId?: string;
nodeRunId?: string;
phaseId?: string;
/** 消息创建时间,供会商式说话人行展示。 */
createdAt?: string;
/** 前端乐观消息(尚未被服务器确认)。 */
optimistic?: boolean;
}
export function collaborationKwargs(message: CollaborationMessage): ReportCollaborationKwargs {
const kwargs: ReportCollaborationKwargs = {};
if (message.agent_run_id) kwargs.agentRunId = message.agent_run_id;
if (message.node_run_id) kwargs.nodeRunId = message.node_run_id;
if (message.phase_id) kwargs.phaseId = message.phase_id;
if (message.created_at) kwargs.createdAt = message.created_at;
return kwargs;
}
// ── 一对一映射 ───────────────────────────────────────────────────────────────
export function toLangGraphMessage(message: CollaborationMessage): Message {
const additional_kwargs: Record<string, unknown> = {};
const rc = collaborationKwargs(message);
if (Object.keys(rc).length > 0) {
additional_kwargs.report_collaboration = rc;
}
if (message.role === "human") {
return {
id: message.message_id,
type: "human",
content: message.content,
additional_kwargs,
} as unknown as Message;
}
if (message.role === "tool") {
return {
id: message.message_id,
type: "tool",
content: message.content,
name: message.name ?? undefined,
tool_call_id: message.tool_call_id ?? "",
additional_kwargs,
} as unknown as Message;
}
// 思考过程:与问答页 additional_kwargs.reasoning_content 对齐,供 MessageGroup CoT 渲染。
const reasoning = message.metadata?.reasoning_content;
if (typeof reasoning === "string" && reasoning.length > 0) {
additional_kwargs.reasoning_content = reasoning;
}
// ai:tool_calls 原样透传(args 流式期间为部分 JSON 字符串)。
return {
id: message.message_id,
type: "ai",
content: message.content,
name: message.name ?? undefined,
tool_calls: message.tool_calls?.length
? message.tool_calls.map((call) => ({
id: call.id,
name: call.name,
args: call.args,
}))
: undefined,
additional_kwargs,
} as unknown as Message;
}
export function toLangGraphMessages(messages: CollaborationMessage[]): Message[] {
return messages.map(toLangGraphMessage);
}
// ── 虚拟业务卡 ───────────────────────────────────────────────────────────────
export type ReportCollaborationCardPayload =
| {
kind: "plan_group";
plans: ReportPlanCandidate[];
selectedPlanId: string | null;
requirementRevision: number;
}
| {
kind: "command_confirm";
command: CollaborationCommand;
}
| {
kind: "report_available";
version: ReportVersion;
versionCount: number;
};
export function hasReportCollaborationCard(message: Message): boolean {
return (
message.type === "ai" &&
typeof (message.additional_kwargs as Record<string, unknown> | undefined)
?.report_collaboration_card === "object"
);
}
export function readReportCollaborationCard(
message: Message,
): ReportCollaborationCardPayload | null {
const payload = (message.additional_kwargs as Record<string, unknown> | undefined)
?.report_collaboration_card;
return (payload as ReportCollaborationCardPayload) ?? null;
}
function buildCardMessage(id: string, payload: ReportCollaborationCardPayload): Message {
return {
id,
type: "ai",
content: "",
additional_kwargs: { report_collaboration_card: payload },
} as unknown as Message;
}
/** 方案组卡(proposal_ready 时注入时间线,点击预览 / 确认后执行)。 */
export function buildPlanGroupCard(
plans: ReportPlanCandidate[],
selectedPlanId: string | null,
requirementRevision: number,
): Message {
const groupId = plans[0]?.proposal_group_id ?? "plans";
return buildCardMessage(`rc-card-plans-${groupId}-r${requirementRevision}`, {
kind: "plan_group",
plans,
selectedPlanId,
requirementRevision,
});
}
/** 命令确认卡(command.confirmation.required 之后)。 */
export function buildCommandConfirmCard(command: CollaborationCommand): Message {
return buildCardMessage(`rc-card-confirm-${command.command_id}`, {
kind: "command_confirm",
command,
});
}
/** 报告入口卡(正式版本落库后)。 */
export function buildReportAvailableCard(version: ReportVersion, versionCount: number): Message {
return buildCardMessage(`rc-card-report-${version.id}`, {
kind: "report_available",
version,
versionCount,
});
}
export interface ReportWriteFileSource {
sessionId: string;
markdown: string;
writing: boolean;
writer?: AgentRun | null;
}
/**
* 把 report.delta / 正式版本投影成原生 write_file 消息,让 MessageList
* 步骤条和 ArtifactFileDetail 沙箱走与问答页相同的渲染路径。
*/
export function buildReportWriteFileMessages(source: ReportWriteFileSource): Message[] {
const { markdown, writing, writer } = source;
if (!writing && !markdown) return [];
const sessionId = source.sessionId || "session";
const messageId = `rc-write-${sessionId}-report`;
const toolCallId = `${messageId}-file`;
const additional_kwargs: Record<string, unknown> = {};
if (writer) {
additional_kwargs.report_collaboration = {
agentRunId: writer.agent_run_id,
nodeRunId: writer.node_run_id ?? undefined,
phaseId: writer.phase_id ?? undefined,
createdAt: writer.started_at ?? undefined,
};
}
const aiMessage = {
id: messageId,
type: "ai" as const,
content: "",
name: writer?.display_name ?? "撰写员",
tool_calls: [
{
id: toolCallId,
name: "write_file",
args: { path: REPORT_OUTPUT_PATH, content: markdown },
},
],
additional_kwargs,
} as unknown as Message;
if (writing) return [aiMessage];
const toolResult = {
id: `${toolCallId}-result`,
type: "tool" as const,
name: "write_file",
tool_call_id: toolCallId,
content: "ok",
additional_kwargs,
} as unknown as Message;
return [aiMessage, toolResult];
}
/** 注入的 write_file 行在聚焦某成员时只保留撰写员自己的。 */
export function reportWriteFileBelongsToFocus(
messages: Message[],
focusedAgentRunId: string | null,
): Message[] {
if (!focusedAgentRunId) return messages;
return messages.filter((message) => {
const kwargs = (message.additional_kwargs as Record<string, unknown> | undefined)
?.report_collaboration as { agentRunId?: string } | undefined;
return kwargs?.agentRunId === focusedAgentRunId;
});
}
// ── BaseStream 外观 ─────────────────────────────────────────────────────────
/**
* 构造 MessageList 需要的最小 `BaseStream<AgentThreadState>`:
* MessageList 只读 `messages` / `isLoading` / `isThreadLoading`。
*/
export function buildBaseStreamFacade(
messages: Message[],
isLoading: boolean,
): BaseStream<AgentThreadState> {
return {
messages,
values: { title: "", messages, artifacts: [], todos: [] },
isLoading,
isThreadLoading: false,
error: undefined,
interrupt: undefined,
stop: async () => undefined,
submit: async () => undefined,
joinStream: async () => undefined,
} as unknown as BaseStream<AgentThreadState>;
}
@@ -0,0 +1,279 @@
/**
* 状态投影 selector:从 CollaborationState 派生页面所需的视图数据。
* 纯函数、无副作用;并行聚焦只过滤本地消息,不改写事实。
*/
import type {
AgentRun,
CollaborationCommand,
CollaborationMessage,
CollaborationNodeState,
ReportPlanCandidate,
} from "@/report-collaboration/api/types";
import type { CollaborationState } from "@/report-collaboration/state/event-reducer";
/** 用户可见消息,按时间线顺序。 */
export function selectOrderedVisibleMessages(state: CollaborationState): CollaborationMessage[] {
return state.messageOrder
.map((id) => state.messagesById[id])
.filter(
(message): message is CollaborationMessage =>
Boolean(message) && message.visibility === "visible",
);
}
/**
* 并行成员聚焦:null = 全量时间线(非并行 / 单成员阶段)。
* 指定 agentRunId 时只保留该成员自己的消息(含指向该成员的 human),
* 不混入协调者、其它并行成员或更早的共享对话。
*/
export function selectMessagesForAgentRun(
messages: CollaborationMessage[],
focusedAgentRunId: string | null,
): CollaborationMessage[] {
if (!focusedAgentRunId) return messages;
return messages.filter((message) => message.agent_run_id === focusedAgentRunId);
}
function isCoordinator(agentRun: AgentRun): boolean {
return /coord/i.test(agentRun.role) || /协调/.test(agentRun.display_name);
}
const IN_FLIGHT_AGENT_STATUS = new Set(["pending", "running", "waiting_input"]);
const IN_FLIGHT_NODE_STATUS = new Set([
"queued",
"running",
"validating",
"awaiting_input",
"cancel_requested",
"reopened",
]);
const TERMINAL_NODE_STATUS = new Set(["completed", "failed", "cancelled", "superseded", "interrupted"]);
const TERMINAL_AGENT_STATUS = new Set(["completed", "failed", "cancelled"]);
function nodeForAgent(state: CollaborationState, agentRun: AgentRun): CollaborationNodeState | null {
const nodeRunId = agentRun.node_run_id;
if (!nodeRunId) return null;
for (const node of Object.values(state.nodes)) {
if (nodeRunId === node.node_id || nodeRunId.startsWith(`${node.node_id}-`)) {
return node;
}
}
return null;
}
/** 该成员是否仍在执行:节点终态优先于 agentRun.status(画布完成即结束)。 */
export function isAgentStillExecuting(state: CollaborationState, agentRun: AgentRun): boolean {
const node = nodeForAgent(state, agentRun);
if (node) {
if (TERMINAL_NODE_STATUS.has(node.status)) return false;
if (IN_FLIGHT_NODE_STATUS.has(node.status)) return true;
}
if (TERMINAL_AGENT_STATUS.has(agentRun.status)) return false;
return IN_FLIGHT_AGENT_STATUS.has(agentRun.status);
}
function membersByPhase(state: CollaborationState): Map<string, AgentRun[]> {
const byPhase = new Map<string, AgentRun[]>();
for (const member of Object.values(state.agentRuns)) {
if (isCoordinator(member)) continue;
const key = member.phase_id ?? "";
const list = byPhase.get(key);
if (list) list.push(member);
else byPhase.set(key, [member]);
}
return byPhase;
}
function latestStartedAt(members: AgentRun[]): string {
return [...members].map((run) => run.started_at ?? "").sort().at(-1) ?? "";
}
/**
* 当前正在执行的那一波成员(不含协调者)。
* 同一 phase 下只要有人仍在执行就返回——单个模型节点也要露出过程;
* 波次结束后回到空数组,消息列表收成普通聊天记录。
* ≥2 人时即为并行波次;1 人时为业务链上的当前节点。
*/
export function selectParallelMembers(state: CollaborationState): AgentRun[] {
const waves = [...membersByPhase(state).entries()]
.map(([phaseId, list]) => ({ phaseId, list }))
.filter(({ list }) => list.length >= 1 && list.some((member) => isAgentStillExecuting(state, member)));
if (waves.length === 0) return [];
const currentPhase = state.run?.current_phase ?? null;
const chosen =
waves.find((wave) => wave.phaseId === currentPhase) ??
[...waves].sort((a, b) => latestStartedAt(b.list).localeCompare(latestStartedAt(a.list)))[0];
return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
}
/** 头像条:当前正在执行的成员(单个模型节点或并行波次,不含协调者)。 */
export function selectAgentTabs(state: CollaborationState): AgentRun[] {
return selectParallelMembers(state);
}
/** 画布节点 → 对应成员(含已完成的顺序节点,供点击后回看该节点的步骤条)。 */
export function selectAgentRunForNode(
state: CollaborationState,
nodeId: string | null,
): AgentRun | null {
if (!nodeId) return null;
const members = Object.values(state.agentRuns).filter((agentRun) => {
const nodeRunId = agentRun.node_run_id;
return typeof nodeRunId === "string" && (nodeRunId === nodeId || nodeRunId.startsWith(`${nodeId}-`));
});
if (members.length === 0) return null;
return (
members.find((member) => isAgentStillExecuting(state, member)) ??
[...members].sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""))[0]
);
}
/** 撰写节点对应的成员(report.delta 注入 write_file 时挂到该归属)。 */
export function selectWriterAgentRun(state: CollaborationState): AgentRun | null {
const byRole = Object.values(state.agentRuns).find(
(agentRun) => /撰写|writer/i.test(agentRun.role) || /撰写/.test(agentRun.display_name),
);
if (byRole) return byRole;
return selectAgentRunForNode(state, "n-write");
}
/** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */
export function selectPreferredParallelFocus(
members: AgentRun[],
streamingIds: Set<string> = new Set(),
): string | null {
if (members.length === 0) return null;
const preferred =
members.find((member) => member.status === "running" || streamingIds.has(member.agent_run_id)) ??
members.find((member) => member.status === "waiting_input") ??
members[0];
return preferred.agent_run_id;
}
/**
* 并行结束后把同一波次各成员的消息收成连续块,好让 MessageList
* 把每个智能体当成一条普通聊天记录(对齐会商第二步收起并行后的列表)。
*/
export function stitchParallelAgentBlocks(
messages: CollaborationMessage[],
state: CollaborationState,
): CollaborationMessage[] {
const phases = [...membersByPhase(state).entries()].filter(([, list]) => list.length >= 2);
if (phases.length === 0) return messages;
const phaseOfAgent = new Map<string, string>();
const agentsOfPhase = new Map<string, AgentRun[]>();
for (const [phaseId, list] of phases) {
const ordered = [...list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
agentsOfPhase.set(phaseId, ordered);
for (const member of ordered) {
phaseOfAgent.set(member.agent_run_id, phaseId);
}
}
const flushed = new Set<string>();
const result: CollaborationMessage[] = [];
for (const message of messages) {
const phaseId = message.agent_run_id ? phaseOfAgent.get(message.agent_run_id) : undefined;
if (!phaseId) {
result.push(message);
continue;
}
if (flushed.has(phaseId)) continue;
flushed.add(phaseId);
for (const member of agentsOfPhase.get(phaseId) ?? []) {
for (const item of messages) {
if (item.agent_run_id === member.agent_run_id) result.push(item);
}
}
}
return result;
}
/** 会话时间线:并行执行中按成员过滤;结束后拼成普通聊天记录。 */
export function selectConversationMessages(
state: CollaborationState,
focusedAgentRunId: string | null,
): CollaborationMessage[] {
const visible = selectOrderedVisibleMessages(state);
if (focusedAgentRunId) return selectMessagesForAgentRun(visible, focusedAgentRunId);
return stitchParallelAgentBlocks(visible, state);
}
/** 画布用计划:预览 > 已选定 > 最新提案组第一张。 */
export function selectPlanForCanvas(
state: CollaborationState,
previewPlanId: string | null,
): ReportPlanCandidate | null {
if (previewPlanId && state.planMap[previewPlanId]) {
return state.planMap[previewPlanId];
}
if (state.selectedPlanId && state.planMap[state.selectedPlanId]) {
return state.planMap[state.selectedPlanId];
}
return state.plans.find((plan) => plan.validation?.ok !== false) ?? state.plans[0] ?? null;
}
/** 运行态节点状态映射(plan 节点 id -> run 节点状态)。 */
export function selectNodeStatusMap(state: CollaborationState): Record<string, CollaborationNodeState> {
return state.nodes;
}
export type PagePhase =
| "empty"
| "clarifying"
| "planning"
| "proposal_ready"
| "running"
| "awaiting_input"
| "reviewing"
| "completed"
| "failed"
| "cancelled";
/** 页面主状态:服务器 session 状态为事实源;awaiting_input 节点细化 running。 */
export function derivePagePhase(state: CollaborationState): PagePhase {
const status = state.session?.status;
if (!status) return "empty";
if (status === "running" && Object.values(state.nodes).some((n) => n.status === "awaiting_input")) {
return "awaiting_input";
}
return status;
}
/** 待用户确认的命令(高成本 / 低置信度)。 */
export function selectPendingConfirmationCommands(state: CollaborationState): CollaborationCommand[] {
return state.commandOrder
.map((id) => state.commands[id])
.filter(
(command): command is CollaborationCommand =>
Boolean(command) && command.status === "confirmation_required",
);
}
export function selectAwaitingInputNodes(state: CollaborationState): CollaborationNodeState[] {
return Object.values(state.nodes).filter((node) => node.status === "awaiting_input");
}
export function selectHeadReportVersion(state: CollaborationState) {
const marked = state.reportVersions.find((version) => version.is_head);
return marked ?? state.reportVersions.at(-1) ?? null;
}
/** 正在流式输出(未完成)的成员消息计数,用于标签上的「回复中」状态。 */
export function selectStreamingAgentRunIds(state: CollaborationState): Set<string> {
const ids = new Set<string>();
for (const id of state.messageOrder) {
const message = state.messagesById[id];
if (
message &&
message.role !== "human" &&
message.agent_run_id &&
!message.completed_at
) {
ids.add(message.agent_run_id);
}
}
return ids;
}
@@ -0,0 +1,65 @@
/**
* 把协作消息投影成会商第二步气泡的说话人身份(头像色 + 席位名)。
* MessageList 本身不展示 `message.name`,报告协作页用这一层补上。
*/
import type { Message } from "@langchain/langgraph-sdk";
import type { AgentRun } from "@/report-collaboration/api/types";
import type { ReportCollaborationKwargs } from "@/report-collaboration/state/message-adapter";
import { FALLBACK_AVATAR_TYPES } from "@/roundtable-planning/lib/roundtable-constants";
export interface ReportSpeaker {
name: string;
role?: string;
avatarType: string;
agentRunId?: string;
time: string;
}
function readKwargs(message: Message): ReportCollaborationKwargs | undefined {
const raw = (message.additional_kwargs as Record<string, unknown> | undefined)
?.report_collaboration;
if (!raw || typeof raw !== "object") return undefined;
return raw as ReportCollaborationKwargs;
}
function hashAvatarType(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return FALLBACK_AVATAR_TYPES[hash % FALLBACK_AVATAR_TYPES.length];
}
function formatSpeakerTime(iso?: string): string {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("zh-CN", { hour12: false });
}
export function resolveReportSpeaker(
messages: Message[],
agentRuns: Record<string, AgentRun>,
): ReportSpeaker | null {
const owner = messages.find((message) => message.type === "ai");
if (!owner) return null;
const kwargs = readKwargs(owner);
const agentRunId = kwargs?.agentRunId;
const run = agentRunId ? agentRuns[agentRunId] : undefined;
const named = typeof owner.name === "string" ? owner.name.trim() : "";
const name = named || run?.display_name || run?.role || "";
if (!name) return null;
const role = run?.role;
const seed = agentRunId || name;
const avatarType =
role === "协调者" || seed.includes("coordinator") ? "blue" : hashAvatarType(seed);
return {
name,
role,
avatarType,
agentRunId,
time: formatSpeakerTime(kwargs?.createdAt),
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { CollaborationEvent, SessionSnapshot } from "../api/types";
import {
applyCollaborationEvents,
applySessionSnapshot,
createInitialCollaborationState,
hasSeqGap,
} from "../state/event-reducer";
const SESSION = "session_test";
const RUN = "run_test";
function ev(seq: number, type: CollaborationEvent["type"], data: unknown): CollaborationEvent {
return {
eventId: `evt_${seq}`,
sessionId: SESSION,
runId: RUN,
seq,
type,
timestamp: "2026-09-02T10:00:00Z",
data,
};
}
function snapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
session: {
id: SESSION,
title: "测试会话",
status: "empty",
requirement_revision: 1,
selected_plan_id: null,
active_run_id: RUN,
current_report_version_id: null,
created_at: "2026-09-02T10:00:00Z",
updated_at: "2026-09-02T10:00:00Z",
},
messages: [],
plans: [],
run: {
id: RUN,
session_id: SESSION,
plan_id: "",
status: "running",
cancel_requested: false,
next_event_seq: 1,
created_at: "2026-09-02T10:00:00Z",
updated_at: "2026-09-02T10:00:00Z",
},
nodes: [],
agent_runs: [],
commands: [],
report_versions: [],
last_seq: 0,
...overrides,
};
}
test("重复事件按 seq 去重,不产生重复消息", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const created = ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "",
agent_run_id: "a1",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
});
state = applyCollaborationEvents(state, [created, created]);
assert.equal(state.messageOrder.length, 1);
assert.equal(state.messagesById.m1.content, "");
});
test("message.delta 多次更新同一 messageId,completed 收尾", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "",
agent_run_id: "a1",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
ev(2, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "你" }),
ev(3, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "好" }),
ev(4, "message.completed", { message_id: "m1" }),
]);
assert.equal(state.messagesById.m1.content, "你好");
assert.ok(state.messagesById.m1.completed_at);
assert.equal(state.lastSeq, 4);
});
test("message.delta 的 reasoning_delta 累加到 metadata.reasoning_content", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m_think",
role: "ai",
content: "",
agent_run_id: "coord",
visibility: "visible",
metadata: { reasoning_content: "先" },
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
ev(2, "message.delta", {
message_id: "m_think",
agent_run_id: "coord",
delta: "",
reasoning_delta: "拆需求",
}),
]);
assert.equal(state.messagesById.m_think.content, "");
assert.equal(state.messagesById.m_think.metadata?.reasoning_content, "先拆需求");
});
test("乱序事件先缓冲,缺口补齐后按序 drain", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
// seq=2 先到 → 缓冲并出现缺口
state = applyCollaborationEvents(state, [
ev(2, "message.delta", { message_id: "m1", delta: "B" }),
]);
assert.equal(state.messagesById.m1, undefined);
assert.equal(hasSeqGap(state), true);
// seq=1 到达 → 1、2 依序应用
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "A",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
]);
assert.equal(state.messagesById.m1.content, "AB");
assert.equal(hasSeqGap(state), false);
});
test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call_id", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "tool_call.created", {
message_id: "ai_owner",
agent_run_id: "a1",
tool_call: { id: "tc1", name: "web_search", args: "" },
}),
ev(2, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '{"q":"' }),
ev(3, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '市场"}' }),
ev(4, "tool_result.created", {
message_id: "tr1",
tool_call_id: "tc1",
name: "web_search",
agent_run_id: "a1",
content: '{"count":8}',
}),
]);
const owner = state.messagesById.ai_owner;
assert.equal(owner.role, "ai");
assert.equal(owner.tool_calls?.[0].id, "tc1");
assert.equal(owner.tool_calls?.[0].args, '{"q":"市场"}');
const toolMessage = state.messagesById.tr1;
assert.equal(toolMessage.role, "tool");
assert.equal(toolMessage.tool_call_id, "tc1");
assert.equal(state.messageOrder[0], "ai_owner");
assert.equal(state.messageOrder[1], "tr1");
assert.ok(owner.completed_at, "工具-only 的 ai 行在 tool 返回后应收尾,避免步骤条一直转圈");
});
test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "node.created", {
node: {
node_id: "n1",
label: "研究",
status: "planned",
attempt: 0,
attempts: [],
},
}),
ev(2, "node.status.changed", { node_id: "n1", status: "running", attempt: 1 }),
// §5.5:running 经 cancel_requested 再到 cancelled
ev(3, "node.status.changed", { node_id: "n1", status: "cancel_requested", attempt: 1 }),
ev(4, "node.status.changed", { node_id: "n1", status: "cancelled", attempt: 1 }),
// 取消后的晚到输出不得改写终态
ev(5, "node.status.changed", { node_id: "n1", status: "validating", attempt: 1 }),
ev(6, "node.status.changed", { node_id: "n1", status: "completed", attempt: 1 }),
]);
assert.equal(state.nodes.n1.status, "cancelled");
// completed -> reopened -> queued 合法
state = applyCollaborationEvents(state, [
ev(7, "node.status.changed", { node_id: "n2", status: "completed", attempt: 1 }),
ev(8, "node.status.changed", { node_id: "n2", status: "reopened", attempt: 1 }),
ev(9, "node.status.changed", { node_id: "n2", status: "queued", attempt: 2 }),
]);
assert.equal(state.nodes.n2.status, "queued");
});
test("协助卡出现(awaiting_input)不等于完成;回到 queued 由状态事件驱动", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "node.created", {
node: { node_id: "n1", label: "研究", status: "running", attempt: 1, attempts: [] },
}),
ev(2, "node.status.changed", {
node_id: "n1",
status: "awaiting_input",
attempt: 1,
awaiting_input: { reason: "需要补充范围", question: "覆盖哪些品牌?" },
}),
]);
assert.equal(state.nodes.n1.status, "awaiting_input");
assert.equal(state.nodes.n1.awaiting_input?.question, "覆盖哪些品牌?");
state = applyCollaborationEvents(state, [
ev(3, "node.status.changed", { node_id: "n1", status: "queued", attempt: 1 }),
]);
assert.equal(state.nodes.n1.status, "queued");
assert.equal(state.nodes.n1.awaiting_input, null);
});
test("并行成员事件按 agentRunId 各自更新,互不影响", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m_market",
role: "ai",
content: "",
agent_run_id: "a_market",
visibility: "visible",
created_at: "t",
completed_at: null,
},
}),
ev(2, "message.created", {
message: {
message_id: "m_brand",
role: "ai",
content: "",
agent_run_id: "a_brand",
visibility: "visible",
created_at: "t",
completed_at: null,
},
}),
ev(3, "message.delta", { message_id: "m_market", agent_run_id: "a_market", delta: "市场" }),
ev(4, "message.delta", { message_id: "m_brand", agent_run_id: "a_brand", delta: "品牌" }),
ev(5, "message.completed", { message_id: "m_market" }),
]);
assert.equal(state.messagesById.m_market.content, "市场");
assert.equal(state.messagesById.m_brand.content, "品牌");
assert.ok(state.messagesById.m_market.completed_at);
assert.equal(state.messagesById.m_brand.completed_at, null);
});
test("乐观用户消息由同 idempotency_key 的服务器镜像原地替换", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "pending:key-1",
role: "human",
content: "写报告",
visibility: "visible",
metadata: { optimistic: true, idempotency_key: "key-1" },
created_at: "t",
completed_at: "t",
},
}),
ev(2, "message.created", {
message: {
message_id: "srv-1",
role: "human",
content: "写报告",
visibility: "visible",
metadata: { idempotency_key: "key-1" },
created_at: "t",
completed_at: "t",
},
}),
]);
assert.equal(state.messageOrder.length, 1);
assert.equal(state.messageOrder[0], "srv-1");
assert.equal(state.messagesById["pending:key-1"], undefined);
});
test("其他 session / 旧 run 的晚到事件被丢弃", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const foreignSession = { ...ev(1, "message.created", { message: { message_id: "x" } }) };
foreignSession.sessionId = "session_other";
const foreignRun = ev(2, "message.created", {
message: {
message_id: "y",
role: "ai",
content: "",
visibility: "visible",
created_at: "t",
completed_at: null,
},
});
foreignRun.runId = "run_old";
state = applyCollaborationEvents(state, [foreignSession, foreignRun]);
assert.equal(state.messageOrder.length, 0);
assert.equal(state.lastSeq, 0);
});
test("plan.proposed 替换方案组;plan.selected 记录选定且不改执行", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const plan = {
id: "plan-1",
proposal_group_id: "g1",
title: "方案一",
strategy: "balanced_review",
summary: "",
rationale: "",
estimated_duration_seconds: 60,
estimated_cost_level: "medium" as const,
requirement_revision: 1,
roles: [],
nodes: [],
edges: [],
quality_gates: [],
report_structure_id: null,
validation: { ok: true, errors: [] },
revision: 1,
status: "proposed" as const,
};
state = applyCollaborationEvents(state, [ev(1, "plan.proposed", { plans: [plan] })]);
assert.equal(state.plans.length, 1);
assert.equal(state.selectedPlanId, null);
state = applyCollaborationEvents(state, [ev(2, "plan.selected", { plan_id: "plan-1" })]);
assert.equal(state.selectedPlanId, "plan-1");
assert.equal(state.plans[0].status, "selected");
assert.equal(state.run?.status, "running"); // 选择不触发执行
});
test("report.delta 累积草稿,version.created 后草稿清空", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "report.delta", { draft_id: "d1", delta: "# 报" }),
ev(2, "report.delta", { draft_id: "d1", delta: "告" }),
]);
assert.equal(state.reportDraft?.markdown, "# 报告");
state = applyCollaborationEvents(state, [
ev(3, "report.version.created", {
version: {
id: "v1",
session_id: SESSION,
version: 1,
parent_version_id: null,
change_type: "initial",
markdown: "# 报告",
created_at: "t",
is_head: true,
},
}),
]);
assert.equal(state.reportDraft, null);
assert.equal(state.reportVersions.length, 1);
assert.equal(state.session?.current_report_version_id, "v1");
});
@@ -0,0 +1,399 @@
import assert from "node:assert/strict";
import test from "node:test";
import { groupMessages } from "../../core/messages/utils";
import {
buildCommandConfirmCard,
buildPlanGroupCard,
buildBaseStreamFacade,
buildReportWriteFileMessages,
REPORT_OUTPUT_PATH,
toLangGraphMessages,
} from "../state/message-adapter";
import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors";
import { resolveReportSpeaker } from "../state/speaker";
import { createInitialCollaborationState } from "../state/event-reducer";
import type { AgentRun, CollaborationMessage } from "../api/types";
function human(id: string, content: string): CollaborationMessage {
return { message_id: id, role: "human", content, visibility: "visible", created_at: "t", completed_at: "t" };
}
function ai(id: string, agentRunId: string | null, content: string): CollaborationMessage {
return {
message_id: id,
role: "ai",
content,
agent_run_id: agentRunId,
name: agentRunId === "a_brand" ? "品牌竞争分析师" : "协调者",
visibility: "visible",
created_at: "t",
completed_at: null,
};
}
test("一对一映射:human/ai/tool 保持原文与归属标记", () => {
const toolMessage: CollaborationMessage = {
message_id: "t1",
role: "tool",
content: '{"count":8}',
name: "web_search",
tool_call_id: "tc1",
agent_run_id: "a_brand",
visibility: "visible",
created_at: "t",
completed_at: "t",
};
const messages = toLangGraphMessages([
human("h1", "写报告"),
{ ...ai("a1", "a_brand", ""), tool_calls: [{ id: "tc1", name: "web_search", args: { q: "市场" } }] },
toolMessage,
]);
assert.equal(messages[0].type, "human");
assert.equal(messages[0].content, "写报告");
assert.equal(messages[1].type, "ai");
const aiMessage = messages[1] as { tool_calls?: { id: string; name: string }[] };
assert.equal(aiMessage.tool_calls?.[0].name, "web_search");
assert.equal(messages[2].type, "tool");
assert.equal((messages[2] as { tool_call_id?: string }).tool_call_id, "tc1");
const kwargs = (messages[1] as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
?.report_collaboration as { agentRunId?: string } | undefined;
assert.equal(kwargs?.agentRunId, "a_brand");
});
test("说话人身份:优先 message.name,缺省时用 agentRun.display_name", () => {
const named = toLangGraphMessages([ai("a1", "a_brand", "品牌结论")])[0];
const fromName = resolveReportSpeaker([named], {
a_brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师" }),
});
assert.equal(fromName?.name, "品牌竞争分析师");
assert.equal(fromName?.agentRunId, "a_brand");
const unnamed: CollaborationMessage = {
message_id: "a2",
role: "ai",
content: "",
agent_run_id: "a_verify",
visibility: "visible",
created_at: "2026-09-05T03:00:00.000Z",
completed_at: null,
tool_calls: [{ id: "tc1", name: "read_file", args: {} }],
};
const fromRun = resolveReportSpeaker(toLangGraphMessages([unnamed]), {
a_verify: agentRun({
agent_run_id: "a_verify",
display_name: "交叉核验",
role: "核验员",
}),
});
assert.equal(fromRun?.name, "交叉核验");
assert.equal(fromRun?.role, "核验员");
});
test("并行聚焦:只保留目标成员自己的消息,不混入 human / 协调者 / 其它成员", () => {
const messages = [
human("h1", "开始"),
ai("c1", null, "我先分派任务"),
ai("m1", "a_market", "市场规模结论……"),
ai("b1", "a_brand", "品牌竞争结论……"),
];
const focusedBrand = selectMessagesForAgentRun(messages, "a_brand");
assert.deepEqual(
focusedBrand.map((message) => message.message_id),
["b1"],
);
// null = 全量视图(单成员 / 协调者阶段)
assert.equal(selectMessagesForAgentRun(messages, null).length, 4);
});
function agentRun(partial: Partial<AgentRun> & Pick<AgentRun, "agent_run_id" | "display_name">): AgentRun {
return {
role: "研究员",
status: "running",
phase_id: "phase_parallel_research",
started_at: "2026-09-03T00:00:00Z",
...partial,
};
}
test("头像条:同一 phase 有人仍在执行即出现,单个模型节点也会露出,不含协调者", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
coord: agentRun({
agent_run_id: "coord",
display_name: "协调者",
role: "协调者",
phase_id: "phase_coordinator",
status: "running",
}),
market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1" }),
brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2" }),
};
const tabs = selectAgentTabs(state);
assert.deepEqual(
tabs.map((tab) => tab.agent_run_id),
["a_market", "a_brand"],
);
assert.equal(selectParallelMembers(state).length, 2);
});
test("头像条:业务链上的单个执行节点也会出现", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
writer: agentRun({
agent_run_id: "agent_run_n-write",
display_name: "报告撰写",
role: "撰写员",
phase_id: "phase_n-write",
node_run_id: "n-write-attempt1",
status: "running",
}),
};
state.nodes = {
"n-write": { node_id: "n-write", label: "报告撰写", status: "running", attempt: 1, attempts: [] },
};
const tabs = selectAgentTabs(state);
assert.equal(tabs.length, 1);
assert.equal(tabs[0].agent_run_id, "agent_run_n-write");
});
test("并行波次全部结束后头像条消失,消息列表恢复全量", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({
agent_run_id: "a_market",
display_name: "市场分析师",
status: "completed",
}),
brand: agentRun({
agent_run_id: "a_brand",
display_name: "品牌竞争分析师",
status: "completed",
}),
};
assert.deepEqual(selectAgentTabs(state), []);
});
test("画布节点完成后即使 agentRun 仍为 running,也收起并行头像条", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({
agent_run_id: "a_market",
display_name: "市场分析师",
node_run_id: "n-market-attempt1",
}),
brand: agentRun({
agent_run_id: "a_brand",
display_name: "品牌竞争分析师",
node_run_id: "n-brand-attempt1",
}),
};
state.nodes = {
"n-market": {
node_id: "n-market",
label: "市场",
status: "completed",
attempt: 1,
attempts: [],
},
"n-brand": {
node_id: "n-brand",
label: "品牌",
status: "completed",
attempt: 1,
attempts: [],
},
};
assert.deepEqual(selectAgentTabs(state), []);
});
test("并行结束后各成员消息收成连续聊天记录,不交叉穿插", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1", status: "completed" }),
brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2", status: "completed" }),
};
const messages = [
human("h1", "开始"),
ai("c1", null, "我先分派任务"),
ai("m1", "a_market", "市场检索中"),
ai("b1", "a_brand", "品牌检索中"),
ai("m2", "a_market", "市场规模结论"),
ai("b2", "a_brand", "品牌竞争结论"),
ai("c2", null, "开始核验"),
];
assert.deepEqual(
stitchParallelAgentBlocks(messages, state).map((message) => message.message_id),
["h1", "c1", "m1", "m2", "b1", "b2", "c2"],
);
state.messageOrder = messages.map((message) => message.message_id);
state.messagesById = Object.fromEntries(messages.map((message) => [message.message_id, message]));
assert.deepEqual(
selectConversationMessages(state, null).map((message) => message.message_id),
["h1", "c1", "m1", "m2", "b1", "b2", "c2"],
);
});
test("业务卡虚拟消息经 groupMessages 识别为 assistant:business-card(集中式扩展)", () => {
const card = buildCommandConfirmCard({
command_id: "cmd1",
idempotency_key: "k",
text: "重新检索",
intent: "research_again",
target_ids: [],
requires_confirmation: true,
status: "confirmation_required",
created_at: "t",
});
const types = groupMessages([card], (group) => group.type);
assert.deepEqual(types, ["assistant:business-card"]);
});
test("方案组卡可被 groupMessages 识别且不产生正文气泡", () => {
const card = buildPlanGroupCard(
[
{
id: "p1",
proposal_group_id: "g1",
title: "方案",
strategy: "balanced_review",
summary: "",
rationale: "",
estimated_duration_seconds: 1,
estimated_cost_level: "low",
requirement_revision: 1,
roles: [],
nodes: [],
edges: [],
quality_gates: [],
report_structure_id: null,
validation: { ok: true, errors: [] },
revision: 1,
status: "proposed",
},
],
null,
1,
);
const types = groupMessages([card], (group) => group.type);
assert.deepEqual(types, ["assistant:business-card"]);
});
test("规划过程:思考+步骤条映射后排在方案卡之前,不是一句总结", () => {
const process: CollaborationMessage = {
message_id: "plan_process",
role: "ai",
content: "",
name: "协调者",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
tool_calls: [{ id: "tc_parse", name: "write_todos", args: { description: "解析需求要点" } }],
};
const toolResult: CollaborationMessage = {
message_id: "tr_parse",
role: "tool",
content: "已提取读者与角度",
name: "write_todos",
tool_call_id: "tc_parse",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
};
const thinking: CollaborationMessage = {
message_id: "plan_think",
role: "ai",
content: "",
name: "协调者",
agent_run_id: "agent_run_coordinator",
visibility: "visible",
created_at: "t",
completed_at: "t",
metadata: { reasoning_content: "先拆读者与覆盖范围,再装配三种策略。" },
};
const output = ai(
"plan_out",
"agent_run_coordinator",
"已装配均衡、深度、快速三种协作策略,下面给出方案卡。",
);
output.completed_at = "t";
const mapped = toLangGraphMessages([process, toolResult, thinking, output]);
const kwargs = (mapped[2] as { additional_kwargs?: Record<string, unknown> }).additional_kwargs;
assert.equal(kwargs?.reasoning_content, "先拆读者与覆盖范围,再装配三种策略。");
const card = buildPlanGroupCard(
[
{
id: "p1",
proposal_group_id: "g1",
title: "方案",
strategy: "balanced_review",
summary: "",
rationale: "",
estimated_duration_seconds: 1,
estimated_cost_level: "low",
requirement_revision: 1,
roles: [],
nodes: [],
edges: [],
quality_gates: [],
report_structure_id: null,
validation: { ok: true, errors: [] },
revision: 1,
status: "proposed",
},
],
null,
1,
);
assert.deepEqual(groupMessages([...mapped, card], (group) => group.type), [
"assistant:processing",
"assistant",
"assistant:business-card",
]);
});
test("BaseStream 外观提供 MessageList 所需字段", () => {
const thread = buildBaseStreamFacade([], true);
assert.deepEqual(thread.messages, []);
assert.equal(thread.isLoading, true);
assert.equal(thread.isThreadLoading, false);
assert.equal(typeof thread.stop, "function");
});
test("report.delta 投影为 write_file:流式中无 tool 返回,完成后补 tool 结果", () => {
const writing = buildReportWriteFileMessages({
sessionId: "s1",
markdown: "# 草稿",
writing: true,
writer: agentRun({
agent_run_id: "agent_run_n-write",
display_name: "报告撰写",
role: "撰写员",
}),
});
assert.equal(writing.length, 1);
const aiMessage = writing[0] as { type: string; tool_calls?: { name: string; args: { path: string; content: string } }[] };
assert.equal(aiMessage.type, "ai");
assert.equal(aiMessage.tool_calls?.[0].name, "write_file");
assert.equal(aiMessage.tool_calls?.[0].args.path, REPORT_OUTPUT_PATH);
assert.equal(aiMessage.tool_calls?.[0].args.content, "# 草稿");
const done = buildReportWriteFileMessages({
sessionId: "s1",
markdown: "# 正式稿",
writing: false,
writer: null,
});
assert.equal(done.length, 2);
assert.equal(done[1].type, "tool");
assert.equal((done[1] as { tool_call_id?: string }).tool_call_id, `${writing[0].id}-file`);
});
test("空草稿且未在写入时不注入 write_file", () => {
assert.deepEqual(
buildReportWriteFileMessages({ sessionId: "s1", markdown: "", writing: false }),
[],
);
});