This commit is contained in:
董宏凯 committed 2026-06-05 19:00:28 +08:00
commit 9a31155581
15 files changed
+467 -90

No files matched your search

+3 -3
View File
@@ -107,7 +107,7 @@ http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest
## 4. 公开 HTTP API(无需鉴权)
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://127.0.0.1:8001`)。
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
@@ -315,7 +315,7 @@ document.getElementById("deerflow-embed").src =
### 5.2 轮询直到结束
```javascript
const API = "http://127.0.0.1:8001"; // VITE_BACKEND_BASE_URL
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
async function pollUntilFinished(sessionId) {
const res = await fetch(
@@ -348,7 +348,7 @@ const timer = setInterval(async () => {
### 5.3 跨域注意
父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
+1 -1
View File
@@ -1,5 +1,5 @@
.sidebar-contnet {
position: fixed;
position: absolute;
left: 0;
top: 4rem;
z-index: 40;
@@ -48,11 +48,17 @@ import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
checkCodeFile,
getFileName,
isImageFile,
} from "@/core/utils/files";
import {
downloadMermaidSvgFile,
isMermaidFile,
openMermaidSvgInNewTab,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import { getBackendBaseURL } from "@/core/config";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { env } from "@/env";
@@ -129,10 +135,14 @@ export function ArtifactFileDetail({
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return language === "html" || language === "markdown";
}, [language]);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const isSupportPreview = useMemo(() => {
return (
language === "html" ||
language === "markdown" ||
language === "mermaid"
);
}, [language]);
const { content, isApplyingEdit } = useArtifactContent({
threadId,
filepath: filepathFromProps,
@@ -150,11 +160,12 @@ export function ArtifactFileDetail({
const displayContent = canEditArtifact ? draftContent : (content ?? "");
const isHtmlStreaming = language === "html" && thread.isLoading;
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
const fileTypeLabel = useMemo(() => {
if (isImageFile(filepath)) return "IMG";
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
if (language === "html") return "HTML";
if (language === "markdown") return "MD";
if (isSkillFile) return "SKILL";
if (language) return language.toUpperCase();
return "FILE";
}, [filepath, isSkillFile, language]);
@@ -423,9 +434,16 @@ export function ArtifactFileDetail({
toast.error("文件内容为空,暂时无法打开");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
if (language === "mermaid") {
void openMermaidSvgInNewTab(source).catch((error) => {
console.error("Failed to open mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
@@ -435,15 +453,22 @@ export function ArtifactFileDetail({
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
if (language === "mermaid") {
void downloadMermaidSvgFile(source, filepath).catch((error) => {
console.error("Failed to download mermaid artifact:", error);
toast.error(error instanceof Error ? error.message : "Download failed");
});
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
@@ -562,14 +587,27 @@ export function ArtifactFileDetail({
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
try {
await openArtifactInNewTab({
filepath,
onClick={async () => {
if (useWriteFileStreaming) {
handleOpenWriteFileInNewTab();
return;
}
if (language === "mermaid") {
try {
await openMermaidSvgInNewTab(displayContent ?? "");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
@@ -614,14 +652,27 @@ export function ArtifactFileDetail({
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
try {
await downloadArtifactFile({
filepath,
onClick={async () => {
if (useWriteFileStreaming) {
handleDownloadWriteFile();
return;
}
if (language === "mermaid") {
try {
await downloadMermaidSvgFile(displayContent ?? "", filepath);
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
return;
}
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
@@ -666,13 +717,15 @@ export function ArtifactFileDetail({
<ArrowDownIcon className="size-4" />
滑动到底部并继续自动下滑
</button>
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
)}
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" ||
language === "html" ||
language === "mermaid") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
threadId={threadId}
publicShareCode={publicShareCode}
animated={showPreviewSwitchPulse}
@@ -768,13 +821,15 @@ export function ArtifactFilePreview({
publicShareCode?: string;
animated?: boolean;
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(null);
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
useEffect(() => {
if (language !== "html") {
setHtmlPreviewUrl(undefined);
return;
}
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
@@ -785,9 +840,40 @@ export function ArtifactFilePreview({
setHtmlPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [content, language]);
URL.revokeObjectURL(url);
};
}, [content, language]);
useEffect(() => {
if (language !== "mermaid") {
setMermaidSvg(null);
setMermaidError(null);
return;
}
let cancelled = false;
setMermaidSvg(null);
setMermaidError(null);
void renderMermaidToSvg(content ?? "")
.then((svg) => {
if (!cancelled) {
setMermaidSvg(svg);
}
})
.catch((error) => {
console.error("Failed to render mermaid preview:", error);
if (!cancelled) {
setMermaidError(
error instanceof Error ? error.message : "Mermaid 渲染失败",
);
}
});
return () => {
cancelled = true;
};
}, [content, language]);
if (language === "markdown") {
return (
@@ -821,9 +907,9 @@ export function ArtifactFilePreview({
);
}
if (language === "html") {
return (
<iframe
if (language === "html") {
return (
<iframe
className={cn(
"size-full bg-white",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
@@ -832,8 +918,41 @@ export function ArtifactFilePreview({
sandbox="allow-scripts allow-forms"
src={htmlPreviewUrl}
/>
);
}
return null;
}
);
}
if (language === "mermaid") {
return (
<div
data-artifact-preview-scroll
className={cn(
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{mermaidError}
</div>
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
{content ?? ""}
</pre>
</div>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
正在生成图表预览
</div>
)}
</div>
);
}
return null;
}
+1 -1
View File
@@ -5,7 +5,7 @@ const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
export const TOOLS_TOKEN_STORAGE_KEY = "tools_token";
export const EXTERNAL_USER_ID_STORAGE_KEY = "userId";
export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo";
export const GENERAL_QA_ROUTE = "/page/strategy/qa/general";
export const GENERAL_QA_ROUTE = "/page/workspace/chats/new";
type UserDetailResponse = {
data?: {
+1 -1
View File
@@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@@ -54,15 +54,13 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
// 需要重新开放时取消注释即可。
// {
// id: "qa-roundtable",
// label: "圆桌规划",
// icon: Bot,
// path: "/page/roundtable/planning",
// },
{
id: "multi-agent-roundtable",
label: "多智能体会商",
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
+5 -4
View File
@@ -94,10 +94,11 @@ const extensionMap: Record<string, string> = {
ini: "ini",
env: "dotenv",
// Markdown & Docs
md: "markdown",
mdx: "mdx",
rst: "rst",
// Markdown & Docs
md: "markdown",
mdx: "mdx",
mmd: "mermaid",
rst: "rst",
// SQL
sql: "sql",
+75
View File
@@ -0,0 +1,75 @@
import { getFileName } from "./files";
let mermaidInitialized = false;
export function isMermaidFile(filepath: string): boolean {
return /\.mmd$/i.test(filepath);
}
export function getMermaidImageFileName(filepath: string, extension = "svg"): string {
return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`);
}
async function getMermaidModule() {
const mod = await import("mermaid");
const mermaid = mod.default;
if (!mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
});
mermaidInitialized = true;
}
return mermaid;
}
export async function renderMermaidToSvg(source: string): Promise<string> {
const text = source.trim();
if (!text) {
throw new Error("Mermaid 内容为空,无法生成图片");
}
const mermaid = await getMermaidModule();
const renderId = `mermaid-preview-${Math.random().toString(36).slice(2, 10)}`;
const host = document.createElement("div");
host.style.position = "fixed";
host.style.left = "-10000px";
host.style.top = "-10000px";
host.style.pointerEvents = "none";
host.style.opacity = "0";
document.body.appendChild(host);
try {
const { svg, bindFunctions } = await mermaid.render(renderId, text);
host.innerHTML = svg;
bindFunctions?.(host);
return host.innerHTML;
} finally {
host.remove();
}
}
export async function openMermaidSvgInNewTab(source: string): Promise<void> {
const svg = await renderMermaidToSvg(source);
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
const win = window.open(objectUrl, "_blank", "noopener,noreferrer");
if (win) {
win.opener = null;
}
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 120_000);
}
export async function downloadMermaidSvgFile(source: string, filepath: string): Promise<void> {
const svg = await renderMermaidToSvg(source);
const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = getMermaidImageFileName(filepath);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
+1 -1
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
ArrowLeftIcon,
+10
View File
@@ -39,6 +39,8 @@ import TaskAnalyzes from "@/strategy-components/components/task-components/TaskA
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export default function StrategyRoutes() {
return (
@@ -59,6 +61,14 @@ export default function StrategyRoutes() {
/>
}
/>
<Route
path="qa/multi-agent-roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} />
@@ -4,6 +4,11 @@ import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import {
downloadMermaidSvgFile,
isMermaidFile,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
@@ -61,6 +66,11 @@ function isMarkdown(mimeType: string | null, name: string): boolean {
return /\.(md|markdown)$/i.test(name);
}
function isMermaid(mimeType: string | null, name: string): boolean {
if (mimeType === "text/vnd.mermaid") return true;
return isMermaidFile(name);
}
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
threadId,
path,
@@ -71,6 +81,7 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
// Esc closes the modal. Attached once per mount.
useEffect(() => {
@@ -116,11 +127,38 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
const handleDownload = useCallback(() => {
if (typeof window === "undefined") return;
if (isMermaid(mimeType, name) && content) {
void downloadMermaidSvgFile(content, name).catch((err) => {
setError((err as Error).message);
});
return;
}
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
window.open(url, "_blank", "noopener,noreferrer");
}, [threadId, path]);
}, [content, mimeType, name, path, threadId]);
const renderAsMarkdown = isMarkdown(mimeType, name);
const renderAsMermaid = isMermaid(mimeType, name);
useEffect(() => {
if (!renderAsMermaid || !content) {
setMermaidSvg(null);
return;
}
let cancelled = false;
void renderMermaidToSvg(content)
.then((svg) => {
if (!cancelled) setMermaidSvg(svg);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
});
return () => {
cancelled = true;
};
}, [content, renderAsMermaid]);
return (
<div
@@ -170,7 +208,19 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
<div className="text-rose-600 text-xs py-6 text-center">
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
) : content === null ? null : renderAsMermaid ? (
mermaidSvg ? (
<div
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs">正在生成图表预览</span>
</div>
)
) : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
@@ -648,7 +648,7 @@ function shouldPreviewInModal(file: { name: string; mimeType: string | null }):
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
@@ -288,6 +288,8 @@ class RoundtableJobExecutor:
idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1)
created = runs[idx].get("createdAt") if idx >= 0 else datetime.now(UTC).isoformat()
prev_step3 = runs[idx].get("step3") if idx >= 0 else None
# 本次有效 step3:新报告优先,否则保留上次(running 中间态 step3=None 不抹掉已存的)。
effective_step3 = step3 if step3 is not None else prev_step3
run = {
"id": run_id,
"title": "后台研讨",
@@ -308,17 +310,21 @@ class RoundtableJobExecutor:
"seatStubMessages": _seat_stub_messages(dialogues),
"orchestrationMode": params.mode,
"chain": params.chain,
"step3": step3 if step3 is not None else prev_step3,
"step3": effective_step3,
"jobId": params.job_id,
"jobStatus": status,
}
runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run]
step2["runs"] = runs
step2["activeRunId"] = run_id
# 顶层 draft.step3 也写一份(同 Step2 把对话写进 run 的同一次落库一并带上)。
# 前端 hydrateStep3 用**顶层** draft.step3 重建报告气泡(结果绘制的聊天记录 + HTML 预览);
# 只存 run 内 step3 时,加载会被 hydrateStep3(draft.step3=None) 覆盖成空 → 报告聊天记录丢失。
update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if status == "done" else 2}
if effective_step3 is not None:
update_kwargs["step3"] = effective_step3
try:
await self._draft_store.update_draft(
params.draft_id, params.user_id, step2=step2, furthest_step=3 if status == "done" else 2
)
await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
except Exception: # noqa: BLE001 — 写草稿失败不影响作业本身
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
@@ -138,7 +138,11 @@ async def run_orchestration(
_SEAT_NO_ASK = (
"注意:这是自动化圆桌研讨,没有用户在线应答——**请勿向用户提问或等待确认**,"
"直接基于已知信息做出合理假设并产出完整的结构化交付(Markdown)。"
"直接基于已知信息做出合理假设,产出完整的结构化交付。\n"
"**交付方式(与前端手动研讨一致)**:请用 write_file 工具把你这一轮的完整交付整理成一份 "
"Markdown 文件,写入 /mnt/user-data/outputs/ 下(文件名体现你的职责,如 `情报收集.md`、"
"`风险评估.md`);同时在回复正文里给出这份交付的内容本身(供总控汇总),"
"不要只回复『已写入文件』这类空话。"
)
@@ -0,0 +1,114 @@
"""一次性巡检:把最新圆桌草稿里「后台作业 run」的关键字段 dump 到文件,
用于验证维度3 存储 + 报告 HTML + 席位 md 文件是否都对齐了。
用法(在 backend 目录):
PYTHONPATH=. uv run python scripts/inspect_latest_roundtable_run.py
输出写到 scripts/_roundtable_run_report.txt(UTF-8),直接打开看,避免 PowerShell GBK 乱码。
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
DB = Path(".deer-flow/data/deerflow.db")
OUT = Path("scripts/_roundtable_run_report.txt")
def _loads(v):
if isinstance(v, (bytes, bytearray)):
v = v.decode("utf-8", "replace")
if isinstance(v, str):
try:
return json.loads(v)
except Exception:
return v
return v
def main() -> None:
con = sqlite3.connect(str(DB))
con.row_factory = sqlite3.Row
lines: list[str] = []
# 最新一条有 step2.runs 的草稿
rows = con.execute(
"SELECT id, step2, step3 FROM roundtable_drafts ORDER BY updated_at DESC LIMIT 5"
).fetchall()
draft = None
top_step3 = None
for r in rows:
s2 = _loads(r["step2"]) or {}
if isinstance(s2, dict) and s2.get("runs"):
draft = (r["id"], s2)
top_step3 = _loads(r["step3"])
break
if not draft:
lines.append("没找到带 step2.runs 的草稿。")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"written: {OUT}")
return
draft_id, s2 = draft
runs = s2.get("runs") or []
job_run = next((x for x in runs if str(x.get("id", "")).startswith("job-")), runs[-1])
lines.append(f"draft_id = {draft_id}")
lines.append(f"run.id = {job_run.get('id')} jobStatus = {job_run.get('jobStatus')} hasConsensus = {job_run.get('hasConsensus')}")
lines.append("")
# ── 维度3:threadIds ──
lines.append("【threadIds】(应为真实 map,不是 null)")
lines.append(json.dumps(job_run.get("threadIds"), ensure_ascii=False, indent=2))
lines.append("")
# ── 维度3:各对话的 steps / agentThreadId / time ──
lines.append("【step2RoundtableDialogues 摘要】")
for d in job_run.get("step2RoundtableDialogues") or []:
steps = d.get("steps") or []
step_keys = [s.get("key") for s in steps]
lines.append(
f"- [{d.get('sender')}] time={d.get('time')!r} thread={d.get('agentThreadId')!r} "
f"steps={step_keys} content_len={len(d.get('content') or '')}"
)
lines.append("")
# ── 席位 md 文件:seatStubMessages 里有没有 write_file ──
lines.append("【seatStubMessages】(席位写的文件 → 文件卡片数据源)")
stubs = job_run.get("seatStubMessages") or {}
if not stubs:
lines.append(" (空 —— 席位没有写文件 / 没采集到)")
for tid, msgs in stubs.items():
for m in msgs:
for tc in m.get("tool_calls") or []:
args = tc.get("args") or {}
lines.append(f" thread={tid} {tc.get('name')} path={args.get('path')!r} content_len={len(str(args.get('content') or ''))}")
lines.append("")
# ── 报告 HTML(P0)──
lines.append("【step3 报告】")
step3 = job_run.get("step3") or {}
html = step3.get("html") or ""
lines.append(f" html_len = {len(html)}")
lines.append(f" 含 <html 标签 = {'<html' in html.lower()} (True=真 HTML / False=纯文字回退=P0未解决)")
lines.append(f" summary = {(step3.get('summary') or '')[:120]!r}")
lines.append("")
# ── 顶层 draft.step3(结果绘制聊天记录靠它重建报告气泡)──
lines.append("【顶层 draft.step3】(前端 hydrateStep3 用它重建报告聊天记录气泡)")
if not top_step3:
lines.append(" ❌ 空 —— 报告聊天记录加载后会丢失(本次修复点:应非空)")
else:
th = (top_step3.get("html") or "") if isinstance(top_step3, dict) else ""
lines.append(f" ✅ 已存 html_len={len(th)} summary={(top_step3.get('summary') or '')[:80]!r}" if isinstance(top_step3, dict) else f" {top_step3!r}")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"written: {OUT.resolve()}")
if __name__ == "__main__":
main()