feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底
支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
65d6b61d5d
commit
f109a95535
65 files changed
+10342
-245
No files matched your search
@@ -77,6 +77,10 @@ import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEd
|
||||
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
|
||||
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
|
||||
import '@/roundtable-planning/styles/roundtable-planning.css';
|
||||
import ReportCollaborationPage, {
|
||||
ReportCollaborationSessionPage,
|
||||
} from '@/report-collaboration/pages/ReportCollaborationPage';
|
||||
import '@/report-collaboration/styles/report-collaboration.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NotebookDetailPageWithKey() {
|
||||
@@ -90,6 +94,29 @@ function NewAgentPageWithKey() {
|
||||
return <NewAgentPage key={thread_id} />;
|
||||
}
|
||||
|
||||
// 报告协作会话页:按 sessionId 重挂(旧 SSE/状态不串台)。
|
||||
function ReportCollaborationSessionRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationSessionPage key={sessionId} sessionId={sessionId ?? ""} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
@@ -728,6 +755,20 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* AgentScope 报告协作工作台:左对话(复用 MessageList)/右画布的独立新页面,
|
||||
不与旧多智能体会商共享状态。key 绑定 sessionId——切换会话重挂干净的页面。 */}
|
||||
<Route
|
||||
path="report-collaboration"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ReportCollaborationPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="report-collaboration/:sessionId"
|
||||
element={<ReportCollaborationSessionRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
{/* Phase 7 临时预览入口:派活链路流程图弹窗(假数据自测,接通后台后可删) */}
|
||||
<Route
|
||||
path="roundtable/chain-preview"
|
||||
|
||||
@@ -273,6 +273,12 @@ function assembleStreamingReport(targets: Record<string, { content: string; vers
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
const REPORT_REFUSAL_PATTERN = /(?:无法|不能|未能|不可以).{0,24}?(?:生成|撰写|产出|输出).{0,12}?报告/s;
|
||||
|
||||
function isRefusalReportText(value: string): boolean {
|
||||
return REPORT_REFUSAL_PATTERN.test(value.replace(/\s+/g, ""));
|
||||
}
|
||||
|
||||
function researchExportBaseName(session: DeepResearchSession): string {
|
||||
const title = (session.title || session.query || "研究报告")
|
||||
.replace(/[\\/:*?"<>|]/g, "-")
|
||||
@@ -416,6 +422,71 @@ function countCollectorMaterials(messages: readonly Message[] | undefined): numb
|
||||
return unique.size;
|
||||
}
|
||||
|
||||
/** Freeze a compact evidence pool for a worker-independent report job. */
|
||||
function snapshotCollectorMaterials(messages: readonly Message[] | undefined): Record<string, unknown>[] {
|
||||
if (!messages?.length) return [];
|
||||
const frozen: Record<string, unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
const text = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
const addCandidate = (candidate: unknown, toolName: string, index: number) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const url = text(item.url) || text(item.source_url) || text(item.link);
|
||||
const id = text(item.id) || text(item.recUuid) || text(item.rec_uuid) || text(item.uuid);
|
||||
const title = text(item.title) || text(item.name) || `检索结果 ${index + 1}`;
|
||||
const content = text(item.content) || text(item.raw_content) || text(item.snippet)
|
||||
|| text(item.content_preview) || text(item.text);
|
||||
if (!url && !id && !content) return;
|
||||
const key = id || url || `${title}\n${content.slice(0, 240)}`;
|
||||
if (seen.has(key) || frozen.length >= 80) return;
|
||||
seen.add(key);
|
||||
frozen.push({
|
||||
id: id || undefined,
|
||||
title: title.slice(0, 500),
|
||||
url: url.slice(0, 2_000) || undefined,
|
||||
raw_content: content.slice(0, 6_000),
|
||||
snippet: content.slice(0, 500) || undefined,
|
||||
source: text(item.source) || toolName || "collector",
|
||||
source_type: toolName === "web_search" ? "web" : "other",
|
||||
published_at: text(item.publish_time) || text(item.published_at) || text(item.time) || undefined,
|
||||
rec_uuid: text(item.recUuid) || text(item.rec_uuid) || undefined,
|
||||
});
|
||||
};
|
||||
const visitPayload = (payload: unknown, toolName: string) => {
|
||||
if (typeof payload === "string") {
|
||||
try {
|
||||
visitPayload(JSON.parse(payload), toolName);
|
||||
} catch {
|
||||
// A plain-text tool result is represented by the collector summary.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
payload.forEach((item, index) => addCandidate(item, toolName, index));
|
||||
return;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ["results", "items", "records", "documents", "hits", "data", "sources"]) {
|
||||
if (Array.isArray(record[key])) {
|
||||
record[key].forEach((item, index) => addCandidate(item, toolName, index));
|
||||
return;
|
||||
}
|
||||
}
|
||||
addCandidate(record, toolName, 0);
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool") {
|
||||
visitPayload(extractToolResultPayload(message), message.name || "collector_tool");
|
||||
}
|
||||
}
|
||||
// Do not disguise the collector's narrative conclusion as a source. A
|
||||
// summary-only snapshot used to look like one successful material, which
|
||||
// prevented the backend from trying the checkpoint/durable fallback that
|
||||
// actually contains the source rows.
|
||||
return frozen;
|
||||
}
|
||||
|
||||
function countEventMaterials(events: ProgressEvent[]): number {
|
||||
const unique = new Set<string>();
|
||||
for (const event of events) {
|
||||
@@ -585,7 +656,7 @@ function DeepResearchWorkbenchInner() {
|
||||
: "";
|
||||
// 全文改写启动后,右侧只渲染临时草稿:首个 token 到达前保持空白,
|
||||
// 不把旧报告混在新稿前面。真实 report.md 仍只在后台校验 + CAS 成功后替换。
|
||||
const displayedReport = reportDocumentRewrite.isActive
|
||||
const rawDisplayedReport = reportDocumentRewrite.isActive
|
||||
? reportDocumentRewrite.run.draft
|
||||
: reportRewritePreview
|
||||
|| (viewingLiveGeneration
|
||||
@@ -593,6 +664,52 @@ function DeepResearchWorkbenchInner() {
|
||||
: viewingParentReport
|
||||
? (session?.reportMarkdown || "")
|
||||
: sandboxVariantSession?.reportMarkdown || "");
|
||||
// Never hide a backend response. Hiding refusal prose also removed the
|
||||
// virtual report file and its sandbox-open trigger, leaving operators with
|
||||
// neither a report nor an error. The backend now replaces refusal output at
|
||||
// its final persistence gate; this client-side signal remains visible when
|
||||
// a worker is stale or a new provider wording escapes that guard.
|
||||
const refusalReportDetected = isRefusalReportText(rawDisplayedReport);
|
||||
const hasUsableDisplayedReport = Boolean(rawDisplayedReport.trim()) && !refusalReportDetected;
|
||||
// A completed session may retain an old diagnostic in `session.error` even
|
||||
// after the backend has produced a usable fallback report. That historical
|
||||
// diagnostic must not be appended to an otherwise normal report.
|
||||
const showRunDiagnostic = Boolean(
|
||||
refusalReportDetected
|
||||
|| (!hasUsableDisplayedReport && (session?.error || session?.status === "failed")),
|
||||
);
|
||||
const runtimeDiagnosticText = JSON.stringify(
|
||||
session?.runtimeDiagnostics ?? {
|
||||
revision: "frontend-fallback-diagnostic",
|
||||
session: {
|
||||
id: session?.id,
|
||||
status: session?.status,
|
||||
sourceCount: session?.sourceCount ?? 0,
|
||||
activeJobId: session?.activeJobId,
|
||||
},
|
||||
error: session?.error ?? "报告正文被识别为拒绝型输出",
|
||||
invalidOutputPreview: refusalReportDetected ? rawDisplayedReport.slice(0, 1_200) : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const diagnosticMarkdown = showRunDiagnostic
|
||||
? [
|
||||
"## 系统运行说明",
|
||||
"",
|
||||
"> 本次报告生成链路出现异常。系统不能据此判断为‘没有收集到素材’,已有素材不会被删除。请将下面的诊断信息复制给管理员排查。",
|
||||
"",
|
||||
"```json",
|
||||
runtimeDiagnosticText,
|
||||
"```",
|
||||
].join("\n")
|
||||
: "";
|
||||
// Invalid refusal prose (or a failed run with no report) is replaced instead
|
||||
// of repeated to the user. Any usable normal/fallback/partial report is shown
|
||||
// as-is and never receives diagnostic text.
|
||||
const displayedReport = showRunDiagnostic
|
||||
? `# 报告生成异常\n\n${diagnosticMarkdown}`
|
||||
: rawDisplayedReport;
|
||||
|
||||
const updateFullReportRewritePresentation = useCallback(
|
||||
(presentationId: string | null, event: DocumentRewriteStreamEvent) => {
|
||||
@@ -1329,6 +1446,7 @@ function DeepResearchWorkbenchInner() {
|
||||
continue;
|
||||
}
|
||||
const version = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion;
|
||||
if (version < reportVersion) continue;
|
||||
const target = typeof event.payload.target === "string" ? event.payload.target : "report";
|
||||
const currentTarget = reportTargets[target];
|
||||
if (!currentTarget || version >= currentTarget.version) {
|
||||
@@ -1858,15 +1976,20 @@ function DeepResearchWorkbenchInner() {
|
||||
try {
|
||||
await actions.startJob.mutateAsync({
|
||||
sessionId: session.id,
|
||||
// First writing must harvest the collector thread before entering the
|
||||
// shared static-material report engine. Structural regeneration can
|
||||
// read already-persisted sources, but a first report cannot assume
|
||||
// that selected source rows exist yet.
|
||||
entry: "chat",
|
||||
config: { ...config, ...modelConfig },
|
||||
collectorMaterials: snapshotCollectorMaterials(collectorThread.messages),
|
||||
});
|
||||
setSandboxOpen(false);
|
||||
toast.success("开始撰写研究报告");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "启动报告撰写失败");
|
||||
}
|
||||
}, [actions.startJob, selectedModel, session]);
|
||||
}, [actions.startJob, collectorThread.messages, selectedModel, session]);
|
||||
|
||||
const handleContinueCollecting = useCallback(() => {
|
||||
document.getElementById("deep-research-composer")?.focus();
|
||||
@@ -2739,7 +2862,7 @@ function ResearchConversation({
|
||||
}) {
|
||||
const active = session.status === "running" || session.status === "awaiting_input";
|
||||
const hasTrace = active || events.length > 0;
|
||||
const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0);
|
||||
const streamedSourceCount = countEventMaterials(events);
|
||||
const liveSourceCount = Math.max(sourceCount, streamedSourceCount);
|
||||
const requestedImageCount = requestedReportImageCount(session.config);
|
||||
return (
|
||||
@@ -2847,6 +2970,7 @@ type TraceSnapshot = {
|
||||
images: TraceImage[];
|
||||
imagePhaseFinished: boolean;
|
||||
warnings: string[];
|
||||
diagnostics: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
function traceRecord(value: unknown): Record<string, unknown> {
|
||||
@@ -2884,6 +3008,8 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
const selectedIds = new Set<string>();
|
||||
const removedIds = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
const diagnostics: Record<string, unknown>[] = [];
|
||||
const diagnosticKeys = new Set<string>();
|
||||
let curationReason: string | undefined;
|
||||
let compressedSourceCount: number | undefined;
|
||||
let reportCompleted = false;
|
||||
@@ -2995,6 +3121,19 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
if (event.type === "warning" || event.type === "error") {
|
||||
const message = traceString(payload.message) ?? traceString(payload.detail);
|
||||
if (message && !warnings.includes(message)) warnings.push(message);
|
||||
const diagnostic = traceRecord(payload.diagnostic);
|
||||
if (Object.keys(diagnostic).length > 0) {
|
||||
const item = {
|
||||
code: traceString(payload.code) ?? event.type,
|
||||
...(message ? { message } : {}),
|
||||
...diagnostic,
|
||||
};
|
||||
const key = JSON.stringify(item);
|
||||
if (!diagnosticKeys.has(key)) {
|
||||
diagnosticKeys.add(key);
|
||||
diagnostics.push(item);
|
||||
}
|
||||
}
|
||||
if (traceString(payload.code) === "image_generation_failed") {
|
||||
const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1));
|
||||
const total = numberValue(payload.total);
|
||||
@@ -3027,6 +3166,7 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot {
|
||||
images: [...imageMap.values()].sort((left, right) => left.position - right.position),
|
||||
imagePhaseFinished,
|
||||
warnings,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3123,6 +3263,7 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla
|
||||
</TraceStage>}
|
||||
|
||||
{snapshot.warnings.length > 0 && <div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-5 text-amber-800 dark:text-amber-200"><p className="font-medium">过程提示</p>{snapshot.warnings.map((warning) => <p key={warning} className="mt-1">{warning}</p>)}</div>}
|
||||
{snapshot.diagnostics.length > 0 && <details className="rounded-lg border border-amber-500/20 bg-amber-500/[0.05] px-3 py-2 text-xs"><summary className="cursor-pointer font-medium text-amber-800 dark:text-amber-200">运行诊断(用于排查,可复制)</summary><pre className="mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-background/70 p-2 text-[11px] leading-5 text-muted-foreground">{snapshot.diagnostics.map((item) => JSON.stringify(item, null, 2)).join("\n\n")}</pre></details>}
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。</p>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,14 @@ export type ReportStubAiMessage = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ReportStubToolMessage = {
|
||||
id: string;
|
||||
type: "tool";
|
||||
name: "write_file";
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
interface UseReportSandboxOptions {
|
||||
/** stub 线程标识(建议用 sessionId/jobId 派生,切换会话即切换 stub)。 */
|
||||
threadKey: string;
|
||||
@@ -87,9 +95,14 @@ export function useReportSandbox({
|
||||
wasWritingRef.current = writing;
|
||||
}, [writing]);
|
||||
|
||||
// 每帧 upsert stub(带防抹空:新帧为空而旧内容非空时保留旧内容)。
|
||||
// 每帧 upsert stub。普通空帧不抹掉已渲染的文件,但写作过程中的
|
||||
// 空正文表示后端要求重置报告版本(report_reset)或新稿尚未出首字,
|
||||
// 此时必须清掉旧版,避免把首版失败文本继续展示在沙箱里。
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
if (!report) {
|
||||
if (writing) setStubMessage(null);
|
||||
return;
|
||||
}
|
||||
setStubMessage((prev) => {
|
||||
const prevArgs = prev?.tool_calls?.[0]?.args as { content?: unknown } | undefined;
|
||||
const prevContent = typeof prevArgs?.content === "string" ? prevArgs.content : "";
|
||||
@@ -106,7 +119,7 @@ export function useReportSandbox({
|
||||
tool_calls: [{ id: toolCallId, name: "write_file", args: { path: REPORT_OUTPUT_PATH, content } }],
|
||||
};
|
||||
});
|
||||
}, [messageId, report, toolCallId]);
|
||||
}, [messageId, report, toolCallId, writing]);
|
||||
|
||||
// 报告首帧到达且仍在写入 → 自动展开右栏并选中虚拟 URL。
|
||||
// openStreamedArtifact 尊重用户手动关闭(suppress),不会强行弹回。
|
||||
@@ -132,8 +145,23 @@ export function useReportSandbox({
|
||||
return {
|
||||
virtualUrl,
|
||||
stubMessages: useMemo(
|
||||
() => (currentStubMessage ? [currentStubMessage] : []),
|
||||
[currentStubMessage],
|
||||
() => {
|
||||
if (!currentStubMessage) return [];
|
||||
if (writing) return [currentStubMessage];
|
||||
// ArtifactFileDetail determines whether a write is still streaming by
|
||||
// matching the write_file call with its tool result. `isLoading=false`
|
||||
// alone is intentionally ignored there, so completed and historical
|
||||
// virtual reports must include this synthetic completion receipt.
|
||||
const completedWrite: ReportStubToolMessage = {
|
||||
id: `${toolCallId}-result`,
|
||||
type: "tool",
|
||||
name: "write_file",
|
||||
tool_call_id: toolCallId,
|
||||
content: "ok",
|
||||
};
|
||||
return [currentStubMessage, completedWrite];
|
||||
},
|
||||
[currentStubMessage, toolCallId, writing],
|
||||
),
|
||||
openReport,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user