代码提交

This commit is contained in:
董宏凯 committed 2026-09-06 20:25:14 +08:00
1 parent 5e953c9fcb
commit bf42a48fcc
16 files changed
+716 -1560

No files matched your search

@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -100,13 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-report-collaboration",
// label: "报告协作",
// icon: FileText,
// path: "/page/workspace/report-collaboration",
// match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
// },
{
id: "workspace-report-collaboration",
label: "报告协作",
icon: FileText,
path: "/page/workspace/report-collaboration",
match: (pathname) => pathname.startsWith("/page/workspace/report-collaboration"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
+12 -13
View File
@@ -3,18 +3,19 @@
实现计划:`docs/agentscope-多智能体报告协作工作台-前端实施计划.md`
(配套后端计划:`docs/agentscope-多智能体报告协作工作台-后端实施计划.md`)。
**进度(2026-09-05):** 本模块页面与契约已完成。后端 `RC-BE-000`~`011` 已通规划前 REST 与 durable SSE;成员自动执行 / 报告改写仍未就绪,联调见后端计划 §0。
**联调对接:** `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 客户端 + Mock 驱动
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 # 真实 / Mock 统一门面(getReportCollaborationDriver)
mock.ts # 本地演示驱动:澄清→3 方案→并行执行→报告版本,全走同一事件协议
driver.ts # 真实后端统一门面(getReportCollaborationDriver;Mock 驱动已移除)
state/
event-normalizer.ts # SSE/REST 事件防御性归一化
event-reducer.ts # durable 事件 reducer(seq 去重、乱序缓冲、节点状态机守卫、乐观消息替换)
@@ -50,20 +51,18 @@ src/report-collaboration/
- 服务器快照 + durable SSE 是唯一事实源;`previewPlanId` / 聚焦成员 / 面板宽度均为纯展示状态。
- 节点抽屉只读合同;重跑/纠偏经对话命令(`targetNodeId`/`agentRunId`)。
## Mock 演示(执行内核未就绪时)
## 只走真实后端
规划前 REST 可走真实后端(需打开 `report_collaboration.enabled`)。成员自动执行、报告改写仍用 mock:
Mock 驱动(`mock.ts`)与切换开关(`deerflow.rc-mock` / `VITE_RC_MOCK`)已移除,
页面永远请求真实后端。历史遗留的浏览器设置可执行
`localStorage.removeItem('deerflow.rc-mock')` 清掉(不再被读取)。日常联调请保证:
- `localStorage.setItem('deerflow.rc-mock','1')` 后刷新;
- 环境变量 `VITE_RC_MOCK=1`。
Mock 走与真实后端相同的驱动接口与事件协议(seq 单调、可重放),可完整演示:
输入需求 → 澄清 → 回答 → 3 个候选方案 → 预览/采用 → 开始协作 → 并行成员流式输出 +
工具调用 → 命令确认卡(输入「重新检索…」)→ 报告版本 → 预览/下载/恢复。
- `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`)。默认 `worker_enabled=false` 仍不挂后台循环。报告改写 / apply / restore 已由 `RC-BE-015` 挂载。后端 20 项质量评测集见 `RC-BE-018`(确定性打分,不替代上线前真人盲评)。
`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`(确定性打分,不替代上线前真人盲评)。
@@ -1,18 +1,11 @@
/**
* 驱动接口:真实后端 / 本地 Mock 的统一门面。
*
* Mock 开关(任一为真即 Mock,便于后端未就绪时演示与联调自测):
* - 环境变量 `VITE_RC_MOCK=1`;
* - localStorage `deerflow.rc-mock` = `"1"`(浏览器里临时打开:
* `localStorage.setItem('deerflow.rc-mock','1')` 后刷新)。
* 驱动接口:直接请求真实后端(不再提供 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 { mockDriver } from "@/report-collaboration/api/mock";
import { ApiError } from "@/report-collaboration/api/client";
import type {
CollaborationCommand,
CollaborationEvent,
@@ -92,7 +85,7 @@ export interface ReportCollaborationDriver {
restoreReportVersion(sessionId: string, versionId: string): Promise<ReportVersion>;
}
const realDriver: ReportCollaborationDriver = {
export const realDriver: ReportCollaborationDriver = {
listSessions: sessionsApi.listSessions,
getSnapshot: sessionsApi.getSessionSnapshot,
createSession: (title) => sessionsApi.createSession(title ? { title } : {}),
@@ -119,113 +112,6 @@ const realDriver: ReportCollaborationDriver = {
restoreReportVersion: reportsApi.restoreReportVersion,
};
let cachedDriver: ReportCollaborationDriver | null = null;
/** DEV 下探测到后端无 /api/report-collaboration(404/网络失败/非 JSON)后自动切 Mock。 */
let devFallbackToMock = false;
export function isMockEnabled(): boolean {
try {
if (import.meta.env.VITE_RC_MOCK === "1") return true;
if (typeof window !== "undefined") {
return window.localStorage.getItem("deerflow.rc-mock") === "1";
}
} catch {
// localStorage 不可用时按真实后端处理。
}
return false;
}
/** 显式关闭(localStorage "0"),优先级高于 DEV 自动回退。 */
function isMockExplicitlyDisabled(): boolean {
try {
return typeof window !== "undefined" && window.localStorage.getItem("deerflow.rc-mock") === "0";
} catch {
return false;
}
}
/** 后端尚未实现该路由的特征:404/405/501、网络失败、或 SPA fallback 返回的非 JSON。 */
function isFeatureMissingError(err: unknown): boolean {
if (err instanceof ApiError) {
// 405:网关对未知 POST 路由的响应形态;404/501:路由未注册。
return err.status === 404 || err.status === 405 || err.status === 501 || err.status === 0;
}
// rcRequest 对 200+HTML(dev server SPA fallback)会抛 JSON.parse 的 SyntaxError。
return err instanceof SyntaxError;
}
async function withDevFallback<T>(op: () => Promise<T>, retry: () => Promise<T>): Promise<T> {
try {
return await op();
} catch (err) {
if (!canDevFallback(err)) throw err;
enableMockFallback();
return retry();
}
}
function canDevFallback(err: unknown): boolean {
return (
import.meta.env.DEV &&
!devFallbackToMock &&
!isMockExplicitlyDisabled() &&
isFeatureMissingError(err)
);
}
function enableMockFallback(): void {
devFallbackToMock = true;
console.info(
"[report-collaboration] 后端 /api/report-collaboration 尚不可用,开发模式自动切换到本地 Mock 演示驱动(localStorage deerflow.rc-mock=0 可强制关闭)。",
);
}
/** DEV 自动回退驱动:命中回退标志后所有方法直达 Mock;否则先走真实后端。 */
const devFallbackEntries: Partial<ReportCollaborationDriver> = {
async listSessions(signal) {
if (devFallbackToMock) return mockDriver.listSessions(signal);
return withDevFallback(
() => realDriver.listSessions(signal),
() => mockDriver.listSessions(signal),
);
},
async getSnapshot(sessionId, signal) {
if (devFallbackToMock) return mockDriver.getSnapshot(sessionId, signal);
const snapshot = await realDriver.getSnapshot(sessionId, signal);
// 真实后端 404 且 DEV 未定态时,可能是功能未部署:探测一次并转 Mock。
if (
snapshot === null &&
import.meta.env.DEV &&
!isMockExplicitlyDisabled() &&
!devFallbackToMock
) {
enableMockFallback();
return mockDriver.getSnapshot(sessionId, signal);
}
return snapshot;
},
async createSession(title) {
if (devFallbackToMock) return mockDriver.createSession(title);
return withDevFallback(
() => realDriver.createSession(title),
() => mockDriver.createSession(title),
);
},
};
const devFallbackDriver: ReportCollaborationDriver = new Proxy(devFallbackEntries, {
get(target, prop, receiver) {
if (Reflect.has(target, prop)) {
return Reflect.get(target, prop, receiver);
}
const active = devFallbackToMock ? mockDriver : realDriver;
const value = Reflect.get(active, prop);
return typeof value === "function" ? value.bind(active) : value;
},
}) as ReportCollaborationDriver;
export function getReportCollaborationDriver(): ReportCollaborationDriver {
if (cachedDriver) return cachedDriver;
cachedDriver = isMockEnabled() ? mockDriver : devFallbackDriver;
return cachedDriver;
return realDriver;
}
File diff suppressed because it is too large. Load diff
@@ -30,6 +30,7 @@ export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: stri
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);
@@ -82,20 +83,70 @@ export function SessionHistoryPanel({ activeSessionId }: { activeSessionId: stri
}
};
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 px-3 py-2">
<span className="text-[12px] text-[var(--rc-text-secondary)]">
<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>
<button
type="button"
className="rc-btn"
onClick={() => navigate("/page/workspace/report-collaboration")}
>
<MessageSquarePlus className="size-3.5" aria-hidden />
新建会话
</button>
<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)" }}>
@@ -288,6 +288,9 @@ export function useReportCollaborationSession(sessionId: string): UseReportColla
idempotencyKey,
);
}
// POST /messages 只回用户消息;协调者澄清/就绪回复写在同一次请求里,
// 必须立刻拉快照,否则本地 status 仍是 empty,轮询不会启动、AI 气泡不出现。
await loadSnapshot(true);
} catch (err) {
// 失败保留乐观消息与输入,提供重试入口(不回滚 UI)。
setActionError(err instanceof Error ? err.message : String(err));
@@ -295,17 +298,18 @@ export function useReportCollaborationSession(sessionId: string): UseReportColla
setSendBusy(false);
}
},
[driver, sessionId, state.run],
[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, sessionId, state.session]);
}, [driver, loadSnapshot, sessionId, state.session]);
const selectPlan = useCallback(
async (planId: string) => {
@@ -4,7 +4,7 @@ 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, Sparkles } from "lucide-react";
import { FileText, Loader2, Sparkles } from "lucide-react";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { ThreadContext } from "@/components/workspace/messages/context";
@@ -341,6 +341,35 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
[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);
@@ -536,19 +565,43 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
: undefined
}
composer={
<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 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>
}
/>
}