Merge branch 'dhk-1' into 'master'
fix(roundtable): 文件卡截断、Step3 完整 HTML 与跨标签页登录体验 See merge request knowledge-base-bj/deerflow-server!85
This commit is contained in:
commit
c05513ae1e
9 files changed
+147
-30
No files matched your search
@@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 我的笔记前端
|
||||
VITE_AIPI_FRONTEND_URL= xxx
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
|
||||
|
||||
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99/api
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
|
||||
|
||||
@@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -715,7 +715,10 @@ export function ArtifactFilePreview({
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content ?? ""], { type: "text/html" });
|
||||
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
|
||||
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(url);
|
||||
|
||||
|
||||
@@ -1,46 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
// 防抖窗口:跨标签页登录/重写 auth 常常在极短时间内连发多个 storage 事件
|
||||
// (例如先写 token、再写用户信息)。等它"落定"再判断,避免抖动期的中间态
|
||||
// (短暂 null / 半写状态)误触发动作。
|
||||
const SETTLE_DELAY_MS = 1200;
|
||||
|
||||
// 提示 toast 的稳定 id —— 同一次身份变化只保留一条,避免连发事件刷屏。
|
||||
const IDENTITY_TOAST_ID = "auth-identity-changed";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||
* Reloading the tab is the simplest way to resync React state with the new
|
||||
* identity.
|
||||
* cross-tab changes.
|
||||
*
|
||||
* 背景:`deerflow.auth` 在同源所有标签页之间共享。某个标签页登录/重写它时,
|
||||
* 其它标签页仍持有旧用户的内存态(会话列表、thread id、以及多智能体会商这类
|
||||
* 纯内存的页面状态),与新 token 不一致,可能在"新对话"等操作上 404。
|
||||
*
|
||||
* 旧实现:只要检测到 user_id 变化就 `window.location.reload()`。这会把**正在
|
||||
* 使用的标签页**(例如正在跑多智能体会商第二步的那个)整页刷新,内存态全丢、
|
||||
* 步骤回到第一步 —— 用户复制链接到新标签页打开(新标签页可能以不同/默认用户
|
||||
* 重新登录并改写共享 auth)就会触发,体验很差。
|
||||
*
|
||||
* 现实现(方案 1,收敛粗暴刷新):
|
||||
* - **真·退出登录**(auth 变为 null)→ 仍然跳登录页并刷新(本就该重新登录)。
|
||||
* - **出现不同的非空用户**(另一个标签页登录成别的身份)→ **不再自动整页刷新**,
|
||||
* 改为弹一条常驻提示,把"是否刷新同步"的决定权交给用户,避免冲掉正在进行的工作。
|
||||
* - 防抖 + 落定后二次校验,过滤掉同用户重写 / 抖动中间态带来的误触发。
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// storage 事件落定后再判断当前的真实身份并决定动作。
|
||||
function evaluateSettledIdentity() {
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
// 同用户重写,或抖动后又回到原身份 —— 无需任何动作。
|
||||
return;
|
||||
}
|
||||
lastUserIdRef.current = nextUserId;
|
||||
|
||||
if (nextUserId === null) {
|
||||
// 另一个标签页退出登录(或清空了 auth)→ 本标签页也该回到登录页。
|
||||
lastUserIdRef.current = null;
|
||||
window.location.replace("#/login");
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
|
||||
// 出现了不同的非空用户。共享 auth 已被改成别的身份,继续在本标签页发请求
|
||||
// 可能 404 / 鉴权失败,但**不**强制刷新 —— 用户可能正在本标签页干活
|
||||
// (如多智能体会商),由用户自己决定何时刷新同步。
|
||||
lastUserIdRef.current = nextUserId;
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
// 只关心 auth 键的变化;event.key === null 表示 localStorage 被整体清空。
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
@@ -118,6 +119,13 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
}) => {
|
||||
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
|
||||
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
|
||||
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
|
||||
const { deselect: deselectArtifact } = useArtifacts();
|
||||
useEffect(() => {
|
||||
deselectArtifact();
|
||||
}, [deselectArtifact]);
|
||||
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
||||
// fetch effect doesn't re-fire on every parent render.
|
||||
const seatThreadEntries = useMemo(() => {
|
||||
|
||||
@@ -521,7 +521,7 @@ function PresentFilesRow({
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
|
||||
{files.map((path) => (
|
||||
<PresentFileCard
|
||||
key={path}
|
||||
@@ -575,18 +575,18 @@ function PresentFileCard({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span
|
||||
className="truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
|
||||
className="min-w-0 flex-1 truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
initReportThread,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
@@ -139,6 +141,11 @@ function buildReportPrompt(opts: {
|
||||
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
|
||||
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
|
||||
);
|
||||
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
|
||||
parts.push(
|
||||
"硬性要求:① 全程使用简体中文输出——HTML 正文、标题、图表标签与图例、按钮、结论摘要等一切文字都必须是中文,禁止输出英文(专有名词可中英并列);" +
|
||||
'② HTML 必须是完整文档,并在 <head> 最前面声明 <meta charset="utf-8">,确保中文不乱码。',
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -543,6 +550,51 @@ export function useStep3Report(
|
||||
[setArtifacts, selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 收尾对账:以沙箱真实文件为准回灌 stub,拿到完整 HTML。
|
||||
// 智能体可能分多次写文件 —— 尤其是 write_file 带 `append=true` 续写后半段(截图里
|
||||
// 「Append remaining HTML content…」那一步)。后端 append 会把后半段拼到磁盘文件已有
|
||||
// 内容后面,磁盘文件是完整的;但前端 stub 按 (name,path) 去重,第二次 write_file 会用
|
||||
// 「只含后半段」的 content 覆盖掉前半段(防抹空守卫只挡空内容、挡不住非空的后半段)。
|
||||
// 这里流式结束后从 outputs 读真实文件回灌 stub,让预览与存档都拿到完整 HTML。
|
||||
// 返回最终用于存草稿的 HTML(拉取失败 / 真实文件更短时退回 stub 内容,不打断收尾)。
|
||||
const reconcileStubFromRealFile = useCallback(
|
||||
async (threadId: string, signal?: AbortSignal): Promise<string> => {
|
||||
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
||||
(tc) => tc.name === "write_file",
|
||||
);
|
||||
const stubPath =
|
||||
typeof (writeTc?.args as { path?: unknown })?.path === "string"
|
||||
? ((writeTc!.args as { path?: string }).path as string)
|
||||
: "";
|
||||
const stubContent =
|
||||
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||
? ((writeTc!.args as { content?: string }).content as string)
|
||||
: "";
|
||||
if (!stubPath) return stubContent;
|
||||
try {
|
||||
const list = await listSessionArtifacts(threadId, { signal });
|
||||
const htmlFile =
|
||||
list.files.find((f) => /\.html?$/i.test(f.name)) ?? null;
|
||||
if (!htmlFile) return stubContent;
|
||||
const res = await apiFetch(
|
||||
buildArtifactDownloadUrl(threadId, htmlFile.path),
|
||||
{ signal },
|
||||
);
|
||||
if (!res.ok) return stubContent;
|
||||
const real = await res.text();
|
||||
// 只在真实文件更长(说明 stub 被后半段截短了)时替换,避免偶发空/短响应回退。
|
||||
if (real.trim() && real.length > stubContent.length) {
|
||||
upsertStub(threadId, "write_file", { path: stubPath, content: real });
|
||||
return real;
|
||||
}
|
||||
} catch {
|
||||
/* 拉取真实文件失败就退回 stub 内容,不打断主流程 */
|
||||
}
|
||||
return stubContent;
|
||||
},
|
||||
[upsertStub],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
aborterRef.current = null;
|
||||
@@ -604,17 +656,16 @@ export function useStep3Report(
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
if (!hasStubFile) await openRealArtifactFallback(threadId);
|
||||
|
||||
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
|
||||
const html = hasStubFile
|
||||
? await reconcileStubFromRealFile(threadId, aborter.signal)
|
||||
: "";
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "绘制完成");
|
||||
setPhase("done");
|
||||
|
||||
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。
|
||||
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
||||
(tc) => tc.name === "write_file",
|
||||
);
|
||||
const html =
|
||||
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||
? ((writeTc!.args as { content?: string }).content as string)
|
||||
: "";
|
||||
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
|
||||
if (html.trim()) {
|
||||
onReportSavedRef.current?.({
|
||||
html,
|
||||
@@ -645,6 +696,7 @@ export function useStep3Report(
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
openRealArtifactFallback,
|
||||
reconcileStubFromRealFile,
|
||||
]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
|
||||
+2
-1
@@ -30,7 +30,7 @@
|
||||
|
||||
| # | 章节 | 内容(文字) | 配图建议 |
|
||||
|---|------|------|----------|
|
||||
| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — |
|
||||
| ① | 报告头 | 标题 + 生成时间占位 | — |
|
||||
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
|
||||
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
|
||||
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
|
||||
@@ -73,4 +73,5 @@
|
||||
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
|
||||
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
|
||||
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
|
||||
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
|
||||
- ❌ 编造与原料明显矛盾的数据
|
||||
Reference in new issue
Block a user