Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
9a31155581
15 files changed
+467
-90
No files matched your search
@@ -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,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;
|
||||
}
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+5
-1
@@ -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()
|
||||
Reference in new issue
Block a user