代码提交
This commit is contained in:
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: "企业深度研究",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user