Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts: # frontend-web/src/components/workspace/agents/agent-card.tsx # frontend-web/src/components/workspace/custom-prompt-menu.tsx
This commit is contained in:
commit
5be21f2c3d
47 files changed
+3065
-530
No files matched your search
@@ -156,8 +156,8 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||
|
||||
// 舆情分析智能体:外部流式问答接口地址(POST,SSE 流式返回)。
|
||||
// 留空时该智能体对话页会提示未配置。
|
||||
VITE_SENTIMENT_AGENT_API_URL: "https://gw.y.sit.4.cn/alg/stream/report-agent",
|
||||
// 前端会把该地址随请求传给后台转发。留空时该智能体对话页会提示未配置。
|
||||
VITE_SENTIMENT_AGENT_API_URL: "http://alg.gw.y.uat.cn/a6_report_agent/alg/stream/report-agent",
|
||||
|
||||
// 舆情分析智能体:请求头 Authorization Basic Auth 用户名。
|
||||
VITE_SENTIMENT_AGENT_AUTH_USERNAME: "1054841959975223296",
|
||||
|
||||
@@ -947,6 +947,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
|
||||
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
|
||||
)}
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
||||
{agent.featured_order != null ? (
|
||||
@@ -1008,7 +1009,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
);
|
||||
|
||||
const listLayout = (
|
||||
<div className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900">
|
||||
<div
|
||||
className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900"
|
||||
data-agent-guide="agent-card"
|
||||
>
|
||||
<AgentBrandIcon className="agent-avatar" size="md" />
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@@ -1093,7 +1097,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
className="max-h-[45vh] min-h-48 resize-y text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -144,6 +144,8 @@ export function AgentDetailPanel({
|
||||
onAskQuestion,
|
||||
disabled,
|
||||
defaultCollapsed,
|
||||
collapsed: collapsedProp,
|
||||
onCollapsedChange,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
@@ -163,6 +165,14 @@ export function AgentDetailPanel({
|
||||
disabled?: boolean;
|
||||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||||
defaultCollapsed?: boolean;
|
||||
/**
|
||||
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||||
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||||
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
@@ -187,7 +197,15 @@ export function AgentDetailPanel({
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
defaultCollapsed ?? false,
|
||||
);
|
||||
const collapsed = collapsedProp ?? internalCollapsed;
|
||||
const setCollapsed = (next: boolean) => {
|
||||
if (collapsedProp === undefined) setInternalCollapsed(next);
|
||||
onCollapsedChange?.(next);
|
||||
};
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
||||
@@ -927,12 +945,14 @@ export function AgentDetailPanel({
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||
<span className="min-w-0 flex-1 truncate whitespace-nowrap text-sm font-semibold">
|
||||
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||||
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||||
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||||
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
</div>
|
||||
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||||
@@ -944,11 +964,37 @@ export function AgentDetailPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</aside>
|
||||
) : collapsedProp !== undefined ? (
|
||||
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
|
||||
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
|
||||
header「新对话」按钮右侧。 */
|
||||
<div
|
||||
className={cn(
|
||||
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
|
||||
collapsed ? "w-0" : "w-[360px]",
|
||||
)}
|
||||
>
|
||||
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
|
||||
{panelChrome(
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="收起智能体信息"
|
||||
aria-expanded={true}
|
||||
>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : collapsed ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { ArrowDownUpIcon, BotIcon, Compass, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -21,6 +21,11 @@ import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/s
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
import {
|
||||
AgentGuide,
|
||||
isAgentGuideEnvironment,
|
||||
startAgentGuide,
|
||||
} from "./agent-guide";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
@@ -398,10 +403,22 @@ export function AgentGallery() {
|
||||
{t.agents.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="重新播放智能体使用引导"
|
||||
onClick={() => startAgentGuide("list")}
|
||||
>
|
||||
<Compass className="mr-1.5 h-4 w-4" />
|
||||
使用引导
|
||||
</Button>
|
||||
<Button onClick={handleNewAgent} data-agent-guide="new-agent-btn">
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
@@ -421,7 +438,10 @@ export function AgentGallery() {
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsList
|
||||
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||
data-agent-guide="tabs"
|
||||
>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
@@ -475,7 +495,8 @@ export function AgentGallery() {
|
||||
</div>
|
||||
</div>
|
||||
{showSearchBar && (
|
||||
<TagSearchBar
|
||||
<div data-agent-guide="search">
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
@@ -506,6 +527,7 @@ export function AgentGallery() {
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -593,6 +615,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
<AgentGuide stage="list" eligible={isAgentGuideEnvironment()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Guide, type GuideStep } from "tdesign-react";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
|
||||
/**
|
||||
* 智能体使用引导(三段式,基于 TDesign React 的 Guide 组件),完整覆盖
|
||||
* 「浏览管理 → 创建与配置 → 发起问答」流程:
|
||||
*
|
||||
* - list :智能体列表页(/page/workspace/agents)——分区、搜索排序、卡片操作、
|
||||
* 创建入口;最后一步「去创建智能体 →」直接跳到创建页衔接下一段。
|
||||
* - create :创建页(/page/workspace/agents/new)——对话式创建、基本信息、模型、
|
||||
* 知识空间、SOUL.md、技能、保存。
|
||||
* - chat :智能体对话页(/page/workspace/agents/:id/chats/...)——侧栏切换与
|
||||
* 历史、常用问题、输入框、右侧详情面板。
|
||||
*
|
||||
* 记忆语义与简洁模式引导(compact-mode-guide.tsx)一致:每个阶段各自记一个
|
||||
* localStorage 标记,**只有用户显式点「完成」或「跳过」才写入**;中途刷新/离开
|
||||
* 不记忆,下次进入该页面还会自动演示。各页头部的「使用引导」按钮可随时强制重放。
|
||||
*
|
||||
* 高亮目标通过 `data-agent-guide` 标记在各组件上;启动时按目标是否存在过滤步骤
|
||||
* (如新用户无智能体卡片、无预设常用问题时自动剔除该步)——Guide 对找不到的
|
||||
* 目标元素会抛错,不能盲传。
|
||||
*/
|
||||
|
||||
export type AgentGuideStage = "list" | "create" | "chat";
|
||||
|
||||
const finishedKey = (stage: AgentGuideStage) =>
|
||||
`deerflow.agent-guide.${stage}.finished`;
|
||||
|
||||
const startEventName = (stage: AgentGuideStage) =>
|
||||
`agent-guide:start:${stage}`;
|
||||
|
||||
/** 手动触发某阶段的引导(各页面头部「使用引导」按钮调用,忽略已看过记忆)。 */
|
||||
export function startAgentGuide(stage: AgentGuideStage) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(startEventName(stage)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 环境闸门:iframe 嵌入 / 去 chrome / 强制收起侧栏(无界)等环境不弹引导。 */
|
||||
export function isAgentGuideEnvironment(): boolean {
|
||||
return (
|
||||
!env.FORCE_COLLAPSED_SIDEBAR &&
|
||||
!isEmbedActive() &&
|
||||
!isHideChromeActive() &&
|
||||
!isWujieEnv()
|
||||
);
|
||||
}
|
||||
|
||||
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||
const TARGET = {
|
||||
// list 阶段(智能体列表页)
|
||||
tabs: '[data-agent-guide="tabs"]',
|
||||
search: '[data-agent-guide="search"]',
|
||||
agentCard: '[data-agent-guide="agent-card"]',
|
||||
newAgentBtn: '[data-agent-guide="new-agent-btn"]',
|
||||
// create 阶段(创建页)
|
||||
createChatInput: '[data-agent-guide="create-chat-input"]',
|
||||
createBasic: '[data-agent-guide="create-basic"]',
|
||||
createModel: '[data-agent-guide="create-model"]',
|
||||
createKnowledge: '[data-agent-guide="create-knowledge"]',
|
||||
createSoul: '[data-agent-guide="create-soul"]',
|
||||
createSkills: '[data-agent-guide="create-skills"]',
|
||||
createSaveBtn: '[data-agent-guide="create-save-btn"]',
|
||||
// chat 阶段(智能体对话页)
|
||||
chatSidebar: '[data-agent-guide="chat-sidebar"]',
|
||||
chatQuestions: '[data-agent-guide="chat-questions"]',
|
||||
chatInput: '[data-agent-guide="chat-input"]',
|
||||
chatDetail: '[data-agent-guide="chat-detail"]',
|
||||
} as const;
|
||||
|
||||
// 等布局稳定后再启动:Guide 只在步骤切换时测量目标位置,起步太早会把高亮框
|
||||
// 定位到过渡中的坐标且不会自行纠正。
|
||||
const START_DELAY_MS = 800;
|
||||
|
||||
interface StepSpec {
|
||||
/** 高亮目标选择器;缺省(dialog 步骤)以 body 兜底。 */
|
||||
selector?: string;
|
||||
mode: "popup" | "dialog";
|
||||
placement: NonNullable<GuideStep["placement"]>;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const LIST_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "欢迎使用智能体",
|
||||
body: "这里是你的智能体中心。接下来花几分钟走完整个流程:浏览与管理智能体 → 创建与配置 → 发起问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.tabs,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "分区浏览",
|
||||
body: "「我的」放着你创建和收藏的智能体;各「广场」是他人发布共享的,可直接使用或收藏;「私密广场」需要密码访问(管理员还可以查看「内置智能体」)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.search,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "搜索与排序",
|
||||
body: "支持关键词、标签搜索,可按最新创建、最近更新、名称排序;右侧按钮可在卡片 / 列表两种视图间切换。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.agentCard,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "智能体卡片",
|
||||
body: "卡片展示名称、简介、模型与技能徽章;底部操作栏支持收藏、置顶、打标签、发布到广场、复制、编辑、删除;点「对话」直接进入该智能体的问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.newAgentBtn,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "创建智能体",
|
||||
body: "准备好了就点这里,开始创建你的专属智能体。点「去创建智能体」直接进入下一站。",
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "创建智能体",
|
||||
body: "页面分两栏:左侧直接与模型对话,让它帮你配置;右侧表单手动填写。两边内容实时联动,选顺手的方式即可。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createChatInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "对话式创建",
|
||||
body: "在这里描述你想要的智能体,例如「帮我做一个周报助手,语气正式、要点条目化」。模型会追问细节并给出配置方案,你确认后方案自动填入右侧表单,可继续微调。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createBasic,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "基本信息",
|
||||
body: "「名称」显示在卡片和对话页;「描述」用一句话说明它擅长什么,方便日后挑选。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createModel,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "运行模型与入库",
|
||||
body: "「模型」选择该智能体被使用时调用的模型;「入库模板名」配置后,对话生成的文档可一键发布入库(不需要可留空)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createKnowledge,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "知识空间",
|
||||
body: "为智能体挂载知识库,回答时会优先检索其中的资料,让结果更贴合你的业务数据。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSoul,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "SOUL.md · 人设",
|
||||
body: "智能体的「灵魂文件」——写清角色定位、能力边界、工作步骤和输出格式。这份提示词直接决定回答质量,是最值得花时间打磨的配置。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSkills,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "技能挂载",
|
||||
body: "从可用技能中勾选要赋予的能力,已选技能会作为工具在对话中被调用;全部不选则是纯对话型智能体。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.createSaveBtn,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "创建并保存",
|
||||
body: "配置完成后点这里保存(左侧对话只是试用场,内容不会自动保存)。保存成功会回到智能体列表。",
|
||||
},
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "开始问答",
|
||||
body: "回到列表页后,点智能体卡片上的「对话」按钮即可开始问答;进入对话页还会有最后一段引导。",
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_STEPS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "智能体问答",
|
||||
body: "一切就绪!这里就是与智能体对话的工作区,快速认识一下。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatSidebar,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "切换与历史",
|
||||
body: "左栏顶部可在「我的 / 广场」智能体之间切换;下方是当前智能体的历史会话,点击任意一条继续上次的对话。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatQuestions,
|
||||
mode: "popup",
|
||||
placement: "bottom",
|
||||
title: "常用问题",
|
||||
body: "智能体预设的开场问题,点击即可直接提问,快速体验它的能力。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "输入框",
|
||||
body: "输入问题后发送即可开始问答;新对话里还可以挂载知识空间,让回答优先检索知识库。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.chatDetail,
|
||||
mode: "popup",
|
||||
placement: "left",
|
||||
title: "智能体详情",
|
||||
body: "右侧面板展示当前智能体的配置(人设、技能、常用问题),可以直接修改并即时生效;里面的快捷提问也能一键发送。",
|
||||
},
|
||||
];
|
||||
|
||||
const STAGE_STEPS: Record<AgentGuideStage, StepSpec[]> = {
|
||||
list: LIST_STEPS,
|
||||
create: CREATE_STEPS,
|
||||
chat: CHAT_STEPS,
|
||||
};
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function isStageFinished(stage: AgentGuideStage): boolean {
|
||||
return (
|
||||
isBrowser() && localStorage.getItem(finishedKey(stage)) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function markStageFinished(stage: AgentGuideStage) {
|
||||
if (isBrowser()) localStorage.setItem(finishedKey(stage), "1");
|
||||
}
|
||||
|
||||
function buildSteps(stage: AgentGuideStage): GuideStep[] {
|
||||
if (!isBrowser()) return [];
|
||||
return STAGE_STEPS[stage]
|
||||
.filter((spec) => !spec.selector || document.querySelector(spec.selector))
|
||||
.map((spec) => ({
|
||||
element: spec.selector ?? (() => document.body),
|
||||
mode: spec.mode,
|
||||
placement: spec.placement,
|
||||
title: spec.title,
|
||||
body: spec.body,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体三段引导的运行组件:`stage` 决定步骤内容,`eligible` 为false 时完全
|
||||
* 静默(不监听、不自动弹)。挂在三个页面各自的作用域内。
|
||||
*/
|
||||
export function AgentGuide({
|
||||
stage,
|
||||
eligible,
|
||||
}: {
|
||||
stage: AgentGuideStage;
|
||||
eligible: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const steps = buildSteps(stage);
|
||||
if (steps.length === 0) return;
|
||||
stepsRef.current = steps;
|
||||
setCurrent(0);
|
||||
}, [stage]);
|
||||
|
||||
// 自动演示:没显式结束过(没点过完成/跳过)时,每次进入该页都演示;是否
|
||||
// 「已看过」只由 finish 回调(完成/跳过)写入。
|
||||
const autoTriedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!eligible || autoTriedRef.current) return;
|
||||
autoTriedRef.current = true;
|
||||
if (isStageFinished(stage)) return;
|
||||
const timer = setTimeout(start, START_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// 组件在计时窗口内卸载(用户迅速离开页面)则下次进入再演示。
|
||||
autoTriedRef.current = false;
|
||||
};
|
||||
}, [eligible, stage, start]);
|
||||
|
||||
// 手动触发:页面头部「使用引导」按钮广播事件,忽略已看过记忆强制重放。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
const handler = () => start();
|
||||
window.addEventListener(startEventName(stage), handler);
|
||||
return () =>
|
||||
window.removeEventListener(startEventName(stage), handler);
|
||||
}, [eligible, stage, start]);
|
||||
|
||||
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」。
|
||||
// list 阶段的最后一步按钮文案是「去创建智能体 →」,完成即跳转衔接下一段。
|
||||
const finish = useCallback(() => {
|
||||
markStageFinished(stage);
|
||||
setCurrent(-1);
|
||||
if (stage === "list") {
|
||||
router.push("/page/workspace/agents/new");
|
||||
}
|
||||
}, [stage, router]);
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
current={current}
|
||||
steps={stepsRef.current}
|
||||
onChange={setCurrent}
|
||||
onFinish={finish}
|
||||
onSkip={finish}
|
||||
finishButtonProps={
|
||||
stage === "list" ? { content: "去创建智能体 →" } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { SentimentAgentProfileDialog } from "./sentiment-agent-profile-dialog";
|
||||
|
||||
/**
|
||||
* 「舆情分析」类型下的虚拟智能体卡片:只存在于前端(不落库 /api/agents),
|
||||
* 对话直连外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 对话经 Gateway 转发外部 AG-UI 网关。名称 / 简介可编辑(localStorage),
|
||||
* 视觉与普通智能体卡片保持一致(网格 / 列表两种布局)。
|
||||
*/
|
||||
export function SentimentAgentCard({
|
||||
|
||||
@@ -152,6 +152,7 @@ export function ThemeSwitcher({ tone }: { tone: AppearanceControlTone }) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
data-compact-guide="theme-switcher"
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
@@ -251,6 +252,7 @@ export function LayoutModeSwitcher({
|
||||
className={cn("size-8 shrink-0 rounded-sm", triggerToneClass(tone))}
|
||||
title="界面模式"
|
||||
aria-label="界面模式"
|
||||
data-compact-guide="layout-mode"
|
||||
>
|
||||
<TriggerIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -76,7 +76,12 @@ import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { isWriteFileInFlight } from "@/core/artifacts/write-file-in-flight";
|
||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
isSandboxStreamPerfEnabled,
|
||||
useSandboxFollowScroll,
|
||||
} from "@/core/artifacts/sandbox-stream-perf";
|
||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
@@ -304,19 +309,33 @@ export function ArtifactFileDetail({
|
||||
const isSpreadsheetFile = useMemo(() => {
|
||||
return /\.xlsx$/i.test(filepath);
|
||||
}, [filepath]);
|
||||
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
|
||||
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
|
||||
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
|
||||
// 仅当「当前这个文件」的 write_file 还没返回 tool result 时才算在写。
|
||||
// 不能用 thread.isLoading:用户协助后的下一轮问答也会 loading,否则会把已写完的
|
||||
// md 从 CodeMirror 拽回巨大 textarea,主线程卡住、聊天变成「发送中然后一口气出来」。
|
||||
const writeFileInFlight = useMemo(() => {
|
||||
if (!isWriteFileVirtual || isImageFile(filepath)) {
|
||||
return false;
|
||||
}
|
||||
return isWriteFileInFlight(thread, filepathFromProps);
|
||||
}, [filepath, filepathFromProps, isWriteFileVirtual, thread.messages]);
|
||||
// 圆桌/主聊天沙箱:write_file 虚拟预览的 md 在该文件写完后切回真实 artifacts API
|
||||
// (可加载 + PATCH 保存)。后续问答轮次的 loading 不再把预览拽回去。
|
||||
const editVirtualMarkdown = useMemo(() => {
|
||||
return (
|
||||
allowVirtualMarkdownEdit &&
|
||||
isWriteFileVirtual &&
|
||||
!isImageFile(filepath) &&
|
||||
!isMock &&
|
||||
!thread.isLoading &&
|
||||
!writeFileInFlight &&
|
||||
checkCodeFile(filepath).language === "markdown"
|
||||
);
|
||||
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
|
||||
}, [
|
||||
allowVirtualMarkdownEdit,
|
||||
isWriteFileVirtual,
|
||||
filepath,
|
||||
isMock,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
||||
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
@@ -373,7 +392,7 @@ export function ArtifactFileDetail({
|
||||
language === "markdown" &&
|
||||
!isSkillFile &&
|
||||
!isMock &&
|
||||
!thread.isLoading &&
|
||||
!writeFileInFlight &&
|
||||
(canEditArtifact || useWriteFileStreaming);
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
@@ -401,6 +420,14 @@ export function ArtifactFileDetail({
|
||||
: (canEditArtifact || (canUseAiEditor && hasLocalEdits)
|
||||
? draftContent
|
||||
: (content ?? ""));
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const isLiveStreaming = writeFileInFlight || documentRewriteActive;
|
||||
const sandboxDisplayText = useMemo(() => {
|
||||
const raw = displayContent ?? "";
|
||||
if (disableDesensitize) return raw;
|
||||
if (streamPerfEnabled && isLiveStreaming) return raw;
|
||||
return desensitize(raw);
|
||||
}, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]);
|
||||
const artifactContentPending =
|
||||
isCodeFile &&
|
||||
!useWriteFileStreaming &&
|
||||
@@ -414,7 +441,7 @@ export function ArtifactFileDetail({
|
||||
const isAutoPreviewLanguage =
|
||||
language === "html" || language === "markdown" || language === "mermaid";
|
||||
const isPreviewStreaming =
|
||||
isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive);
|
||||
isAutoPreviewLanguage && (writeFileInFlight || documentRewriteActive);
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
@@ -877,7 +904,7 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
setViewMode(writeFileInFlight || documentRewriteActive ? "code" : "preview");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -887,14 +914,20 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
|
||||
setViewMode("code");
|
||||
}, [isPreviewStreaming, isSupportPreview, language, thread.isLoading]);
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
isPreviewStreaming,
|
||||
isSupportPreview,
|
||||
language,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowAutoViewSwitch || !isAutoPreviewLanguage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (thread.isLoading) {
|
||||
if (writeFileInFlight || documentRewriteActive) {
|
||||
setViewMode("code");
|
||||
return;
|
||||
}
|
||||
@@ -906,7 +939,13 @@ export function ArtifactFileDetail({
|
||||
}, 1400);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [allowAutoViewSwitch, isAutoPreviewLanguage, isSupportPreview, thread.isLoading]);
|
||||
}, [
|
||||
allowAutoViewSwitch,
|
||||
documentRewriteActive,
|
||||
isAutoPreviewLanguage,
|
||||
isSupportPreview,
|
||||
writeFileInFlight,
|
||||
]);
|
||||
|
||||
const scrollArtifactToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "auto") => {
|
||||
@@ -924,39 +963,12 @@ export function ArtifactFileDetail({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
scrollArtifactToBottom("auto");
|
||||
};
|
||||
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, 180);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [
|
||||
autoScrollEnabled,
|
||||
documentRewriteActive,
|
||||
scrollArtifactToBottom,
|
||||
thread.isLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollArtifactToBottom("auto");
|
||||
}, [
|
||||
autoScrollEnabled,
|
||||
displayContent,
|
||||
documentRewriteActive,
|
||||
scrollArtifactToBottom,
|
||||
thread.isLoading,
|
||||
useSandboxFollowScroll({
|
||||
follow: isLiveStreaming && autoScrollEnabled,
|
||||
scrollToBottom: scrollArtifactToBottom,
|
||||
contentKey: sandboxDisplayText,
|
||||
viewMode,
|
||||
]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResumeEvent = () => {
|
||||
@@ -1030,13 +1042,13 @@ export function ArtifactFileDetail({
|
||||
}, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]);
|
||||
|
||||
const handlePauseAutoScroll = useCallback(() => {
|
||||
if (!thread.isLoading || !autoScrollEnabled) {
|
||||
if (!isLiveStreaming || !autoScrollEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoScrollEnabled(false);
|
||||
setShowResumeAutoScroll(true);
|
||||
}, [autoScrollEnabled, thread.isLoading]);
|
||||
}, [autoScrollEnabled, isLiveStreaming]);
|
||||
|
||||
const handleResumeAutoScroll = useCallback(() => {
|
||||
setAutoScrollEnabled(true);
|
||||
@@ -1743,7 +1755,7 @@ export function ArtifactFileDetail({
|
||||
language === "html" ||
|
||||
language === "mermaid") && (
|
||||
<ArtifactFilePreview
|
||||
content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
|
||||
content={sandboxDisplayText}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
@@ -1780,7 +1792,7 @@ export function ArtifactFileDetail({
|
||||
value={
|
||||
canEditArtifact || disableDesensitize
|
||||
? (displayContent ?? "")
|
||||
: desensitize(displayContent ?? "")
|
||||
: sandboxDisplayText
|
||||
}
|
||||
readonly={!canEditArtifact || documentRewriteActive}
|
||||
onChange={
|
||||
@@ -1791,6 +1803,7 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isStreaming={writeFileInFlight || documentRewriteActive}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,11 @@ import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
const customDarkTheme = monokaiInit({
|
||||
settings: {
|
||||
background: "transparent",
|
||||
@@ -34,7 +33,7 @@ const customLightTheme = basicLightInit({
|
||||
},
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
export const CodeEditor = memo(function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
@@ -43,6 +42,7 @@ export function CodeEditor({
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
isStreaming = false,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -53,11 +53,10 @@ export function CodeEditor({
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
/** Parent-owned streaming flag so this editor does not subscribe to `thread`. */
|
||||
isStreaming?: boolean;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
} = useThread();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
@@ -81,7 +80,7 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
{isStreaming && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
@@ -116,4 +115,4 @@ export function CodeEditor({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Guide, type GuideStep } from "tdesign-react";
|
||||
|
||||
import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu";
|
||||
|
||||
/**
|
||||
* 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的
|
||||
* Guide 组件(https://tdesign.tencent.com/react/components/guide)。
|
||||
*
|
||||
* 触发方式:
|
||||
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
|
||||
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
|
||||
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
|
||||
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
|
||||
* /chats/new,由本组件在挂载后消费。
|
||||
*
|
||||
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
|
||||
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
|
||||
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
|
||||
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
|
||||
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
|
||||
*/
|
||||
|
||||
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
|
||||
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
|
||||
|
||||
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
|
||||
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
|
||||
|
||||
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||||
const TARGET = {
|
||||
nav: '[data-compact-guide="sidebar-nav"]',
|
||||
recentChats: '[data-compact-guide="recent-chats"]',
|
||||
settingsMenu: '[data-compact-guide="settings-menu"]',
|
||||
themeSwitcher: '[data-compact-guide="theme-switcher"]',
|
||||
layoutMode: '[data-compact-guide="layout-mode"]',
|
||||
promptBar: '[data-compact-guide="prompt-bar"]',
|
||||
promptDialog: '[data-compact-guide="prompt-dialog"]',
|
||||
promptInput: '[data-compact-guide="prompt-input"]',
|
||||
} as const;
|
||||
|
||||
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
|
||||
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
|
||||
const START_DELAY_MS = 800;
|
||||
|
||||
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
|
||||
// 跳过该步避免引导卡死。
|
||||
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
export function isCompactGuideDone(): boolean {
|
||||
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
|
||||
}
|
||||
|
||||
function markCompactGuideDone() {
|
||||
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
|
||||
}
|
||||
|
||||
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
|
||||
export function requestCompactGuideStart() {
|
||||
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
|
||||
}
|
||||
|
||||
function hasCompactGuideRequest(): boolean {
|
||||
return (
|
||||
isBrowser() &&
|
||||
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
|
||||
);
|
||||
}
|
||||
|
||||
function consumeCompactGuideRequest() {
|
||||
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
|
||||
}
|
||||
|
||||
interface StepSpec {
|
||||
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
|
||||
selector?: string;
|
||||
/**
|
||||
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
|
||||
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
|
||||
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
|
||||
*/
|
||||
dependsOn?: string;
|
||||
mode: "popup" | "dialog";
|
||||
placement: NonNullable<GuideStep["placement"]>;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const STEP_SPECS: StepSpec[] = [
|
||||
{
|
||||
mode: "dialog",
|
||||
placement: "center",
|
||||
title: "欢迎使用简洁模式",
|
||||
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.nav,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "功能导航",
|
||||
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.recentChats,
|
||||
mode: "popup",
|
||||
placement: "right",
|
||||
title: "聊天记录",
|
||||
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.settingsMenu,
|
||||
mode: "popup",
|
||||
placement: "right-top",
|
||||
title: "设置和更多",
|
||||
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.themeSwitcher,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "主题换肤",
|
||||
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.layoutMode,
|
||||
mode: "popup",
|
||||
placement: "bottom-left",
|
||||
title: "切回全量模式",
|
||||
body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptBar,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "常用提示词",
|
||||
body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptDialog,
|
||||
dependsOn: TARGET.promptBar,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "管理常用提示词",
|
||||
body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。",
|
||||
},
|
||||
{
|
||||
selector: TARGET.promptInput,
|
||||
mode: "popup",
|
||||
placement: "top",
|
||||
title: "开始提问",
|
||||
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
|
||||
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
|
||||
* 满足「首次进入」条件时自动演示一遍。
|
||||
*/
|
||||
export function CompactModeGuide({
|
||||
eligible,
|
||||
autoStart = false,
|
||||
}: {
|
||||
eligible: boolean;
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const specsRef = useRef<StepSpec[]>([]);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
const pollTimerRef = useRef<number | null>(null);
|
||||
// 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。
|
||||
const promptDialogTakenRef = useRef(false);
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current !== null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setPromptDialog = useCallback(
|
||||
(open: boolean, takeOver: boolean) => {
|
||||
promptDialogTakenRef.current = takeOver;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, {
|
||||
detail: { open, owner: takeOver },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const releasePromptDialog = useCallback(() => {
|
||||
if (!promptDialogTakenRef.current) return;
|
||||
setPromptDialog(false, false);
|
||||
}, [setPromptDialog]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
const specs = STEP_SPECS.filter(
|
||||
(spec) =>
|
||||
!spec.selector || document.querySelector(spec.dependsOn ?? spec.selector),
|
||||
);
|
||||
if (specs.length === 0) return;
|
||||
specsRef.current = specs;
|
||||
stepsRef.current = specs.map((spec) => ({
|
||||
element: spec.selector ?? (() => document.body),
|
||||
mode: spec.mode,
|
||||
placement: spec.placement,
|
||||
title: spec.title,
|
||||
body: spec.body,
|
||||
}));
|
||||
setCurrent(0);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」
|
||||
// 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
|
||||
// 又会被当成已看过。
|
||||
const autoTriedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||||
autoTriedRef.current = true;
|
||||
if (isCompactGuideDone()) return;
|
||||
const timer = setTimeout(start, START_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
|
||||
autoTriedRef.current = false;
|
||||
};
|
||||
}, [eligible, autoStart, start]);
|
||||
|
||||
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
|
||||
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
|
||||
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
if (!hasCompactGuideRequest()) return;
|
||||
const timer = setTimeout(() => {
|
||||
consumeCompactGuideRequest();
|
||||
start();
|
||||
}, START_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [eligible, start]);
|
||||
|
||||
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
const handler = () => start();
|
||||
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||
return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||||
}, [eligible, start]);
|
||||
|
||||
// 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
};
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
// 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析
|
||||
// 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时
|
||||
// onChange 也会收到 -1,与 finish 回调各自幂等。
|
||||
const goTo = (next: number) => {
|
||||
const specs = specsRef.current;
|
||||
clearPollTimer();
|
||||
if (next < 0 || next >= specs.length) {
|
||||
releasePromptDialog();
|
||||
setCurrent(-1);
|
||||
return;
|
||||
}
|
||||
if (specs[next].selector === TARGET.promptDialog) {
|
||||
setPromptDialog(true, true);
|
||||
const startedAt = Date.now();
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
if (document.querySelector(TARGET.promptDialog)) {
|
||||
clearPollTimer();
|
||||
setCurrent(next);
|
||||
} else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
goTo(next + 1 >= specs.length ? -1 : next + 1);
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
releasePromptDialog();
|
||||
setCurrent(next);
|
||||
};
|
||||
|
||||
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或
|
||||
// 离开不记忆,下次进入简洁模式问答页还会再演示。
|
||||
const finish = useCallback(() => {
|
||||
clearPollTimer();
|
||||
releasePromptDialog();
|
||||
markCompactGuideDone();
|
||||
setCurrent(-1);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
current={current}
|
||||
steps={stepsRef.current}
|
||||
onChange={goTo}
|
||||
onFinish={finish}
|
||||
onSkip={finish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,13 @@ import {
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
|
||||
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
|
||||
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
|
||||
*/
|
||||
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
|
||||
|
||||
export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
@@ -58,10 +65,23 @@ export function CustomPromptBar({
|
||||
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
||||
);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const guideOwnsDialogRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = useState(false);
|
||||
const [canRight, setCanRight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (
|
||||
event as CustomEvent<{ open?: boolean; owner?: boolean }>
|
||||
).detail;
|
||||
guideOwnsDialogRef.current = Boolean(detail?.owner);
|
||||
setManageOpen(Boolean(detail?.open));
|
||||
};
|
||||
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -89,6 +109,7 @@ export function CustomPromptBar({
|
||||
compact && "min-h-9 justify-end",
|
||||
className,
|
||||
)}
|
||||
data-compact-guide="prompt-bar"
|
||||
>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
@@ -161,7 +182,14 @@ export function CustomPromptBar({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
<ManagePromptsDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={(next) => {
|
||||
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
|
||||
if (!next && guideOwnsDialogRef.current) return;
|
||||
setManageOpen(next);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -270,7 +298,10 @@ function ManagePromptsDialog({
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogContent
|
||||
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
|
||||
data-compact-guide="prompt-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -206,7 +206,10 @@ export function RecentChatList({
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroup
|
||||
className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"
|
||||
data-compact-guide="recent-chats"
|
||||
>
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
||||
|
||||
@@ -176,7 +176,10 @@ export function WorkspaceNavChatList({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
<SidebarGroup
|
||||
className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}
|
||||
data-compact-guide="sidebar-nav"
|
||||
>
|
||||
<SidebarMenu className="gap-1">
|
||||
{(showCollapseTrigger || newChatItem) && (
|
||||
<SidebarMenuItem>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BriefcaseIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
Compass,
|
||||
CpuIcon,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -46,6 +48,10 @@ import {
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import {
|
||||
COMPACT_GUIDE_START_EVENT,
|
||||
requestCompactGuideStart,
|
||||
} from "./compact-mode-guide";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
@@ -73,6 +79,7 @@ function NavMenuButtonContent({
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
@@ -90,6 +97,20 @@ export function WorkspaceNavMenu() {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
|
||||
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
||||
const startCompactGuide = () => {
|
||||
if (/^\/page\/workspace\/chats\/.+/.test(pathname)) {
|
||||
window.dispatchEvent(new CustomEvent(COMPACT_GUIDE_START_EVENT));
|
||||
return;
|
||||
}
|
||||
requestCompactGuideStart();
|
||||
navigate("/page/workspace/chats/new", {
|
||||
replace: true,
|
||||
state: { resetAt: Date.now() },
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
@@ -116,7 +137,7 @@ export function WorkspaceNavMenu() {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
@@ -155,6 +176,10 @@ export function WorkspaceNavMenu() {
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={startCompactGuide}>
|
||||
<Compass />
|
||||
界面使用引导
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
import {
|
||||
loadArtifactContent,
|
||||
loadArtifactContentFromLatestWriteFile,
|
||||
loadArtifactContentFromToolCall,
|
||||
} from "./loader";
|
||||
import {
|
||||
SANDBOX_STREAM_FLUSH_MS,
|
||||
isSandboxStreamPerfEnabled,
|
||||
} from "./sandbox-stream-perf";
|
||||
import { isArtifactApiPath } from "./streaming-write-preview";
|
||||
import { isWriteFileInFlight, isWriteFileStreamingUrl } from "./write-file-in-flight";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(filepath);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
return !isImageFile(path);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
||||
function hasToolResult(
|
||||
thread: ReturnType<typeof useThread>["thread"],
|
||||
toolCallId?: string,
|
||||
) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
@@ -81,6 +74,48 @@ function applyPendingTextEdit({
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the latest write_file text in a ref (always current for later readers)
|
||||
* but only publish a display snapshot on a timer while the run is in flight.
|
||||
* Display therefore does not follow `thread` object identity / every SSE chunk.
|
||||
*/
|
||||
function useThrottledWriteFileDisplay({
|
||||
filepath,
|
||||
liveContent,
|
||||
enabled,
|
||||
streaming,
|
||||
}: {
|
||||
filepath: string;
|
||||
liveContent: string | undefined;
|
||||
enabled: boolean;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const latestRef = useRef(liveContent);
|
||||
latestRef.current = liveContent;
|
||||
const [displayed, setDisplayed] = useState(liveContent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setDisplayed(latestRef.current);
|
||||
return;
|
||||
}
|
||||
const flush = () => {
|
||||
setDisplayed((previous) =>
|
||||
Object.is(previous, latestRef.current) ? previous : latestRef.current,
|
||||
);
|
||||
};
|
||||
if (!streaming) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
const intervalId = window.setInterval(flush, SANDBOX_STREAM_FLUSH_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [enabled, filepath, streaming]);
|
||||
|
||||
return enabled && streaming ? displayed : liveContent;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
@@ -94,12 +129,19 @@ export function useArtifactContent({
|
||||
return isWriteFileStreamingUrl(filepath);
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, useWriteFileStreaming, thread]);
|
||||
const streamPerfEnabled = isSandboxStreamPerfEnabled();
|
||||
const liveWriteFileContent = useWriteFileStreaming
|
||||
? loadArtifactContentFromToolCall({ url: filepath, thread })
|
||||
: undefined;
|
||||
const writeFileInFlight =
|
||||
useWriteFileStreaming && isWriteFileInFlight(thread, filepath);
|
||||
const displayedWriteFileContent = useThrottledWriteFileDisplay({
|
||||
filepath,
|
||||
liveContent: liveWriteFileContent,
|
||||
enabled: streamPerfEnabled && useWriteFileStreaming,
|
||||
streaming: writeFileInFlight,
|
||||
});
|
||||
const content = useWriteFileStreaming ? displayedWriteFileContent : null;
|
||||
const toolCallFallbackContent = useMemo(() => {
|
||||
if (useWriteFileStreaming || !isArtifactApiPath(filepath)) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SANDBOX_STREAM_PERF_QUERY,
|
||||
resolveSandboxStreamPerfEnabled,
|
||||
} from "./sandbox-stream-perf";
|
||||
|
||||
test("sandbox stream perf defaults to on", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({}), true);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: null }), true);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from hash or search query", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=0",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
search: `?${SANDBOX_STREAM_PERF_QUERY}=off`,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/agents/abc/chats/def?foo=1&sandboxStreamPerf=false",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf query wins over localStorage", () => {
|
||||
assert.equal(
|
||||
resolveSandboxStreamPerfEnabled({
|
||||
hash: "#/page/workspace/chats/new?sandboxStreamPerf=1",
|
||||
storageValue: "0",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("sandbox stream perf can be rolled back from localStorage", () => {
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "0" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "off" }), false);
|
||||
assert.equal(resolveSandboxStreamPerfEnabled({ storageValue: "1" }), true);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Sandbox streaming performance guard (write_file live preview).
|
||||
*
|
||||
* Default ON. Rollback without a rebuild:
|
||||
* 1. Append `?sandboxStreamPerf=0` to the page URL and refresh
|
||||
* e.g. `/#/page/workspace/chats/new?sandboxStreamPerf=0`
|
||||
* 2. Or in the console:
|
||||
* localStorage.setItem("deerflow.sandbox-stream-perf", "0"); location.reload();
|
||||
*
|
||||
* Restore the optimized path:
|
||||
* localStorage.removeItem("deerflow.sandbox-stream-perf") and drop the query, then refresh.
|
||||
*/
|
||||
export const SANDBOX_STREAM_PERF_STORAGE_KEY = "deerflow.sandbox-stream-perf";
|
||||
export const SANDBOX_STREAM_PERF_QUERY = "sandboxStreamPerf";
|
||||
/** Aligns with roundtable write_file preview (~12fps). */
|
||||
export const SANDBOX_STREAM_FLUSH_MS = 80;
|
||||
export const SANDBOX_LEGACY_SCROLL_INTERVAL_MS = 180;
|
||||
|
||||
function parseFlag(raw: string | null | undefined): boolean | null {
|
||||
if (raw == null) return null;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (value === "0" || value === "off" || value === "false" || value === "no") {
|
||||
return false;
|
||||
}
|
||||
if (value === "1" || value === "on" || value === "true" || value === "yes") {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryValue(search: string, key: string): string | null {
|
||||
if (!search) return null;
|
||||
const normalized = search.startsWith("?") ? search.slice(1) : search;
|
||||
return new URLSearchParams(normalized).get(key);
|
||||
}
|
||||
|
||||
export function resolveSandboxStreamPerfEnabled(input: {
|
||||
search?: string;
|
||||
hash?: string;
|
||||
storageValue?: string | null;
|
||||
}): boolean {
|
||||
const searchFlag = parseFlag(queryValue(input.search ?? "", SANDBOX_STREAM_PERF_QUERY));
|
||||
if (searchFlag != null) return searchFlag;
|
||||
|
||||
const hash = input.hash ?? "";
|
||||
const hashQueryIndex = hash.indexOf("?");
|
||||
if (hashQueryIndex >= 0) {
|
||||
const hashFlag = parseFlag(
|
||||
queryValue(hash.slice(hashQueryIndex), SANDBOX_STREAM_PERF_QUERY),
|
||||
);
|
||||
if (hashFlag != null) return hashFlag;
|
||||
}
|
||||
|
||||
const storedFlag = parseFlag(input.storageValue);
|
||||
if (storedFlag != null) return storedFlag;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isSandboxStreamPerfEnabled(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
let storageValue: string | null = null;
|
||||
try {
|
||||
storageValue = window.localStorage.getItem(SANDBOX_STREAM_PERF_STORAGE_KEY);
|
||||
} catch {
|
||||
storageValue = null;
|
||||
}
|
||||
return resolveSandboxStreamPerfEnabled({
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
storageValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSandboxFollowScroll({
|
||||
follow,
|
||||
scrollToBottom,
|
||||
contentKey,
|
||||
viewMode,
|
||||
}: {
|
||||
follow: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => boolean;
|
||||
contentKey: string;
|
||||
viewMode: string;
|
||||
}) {
|
||||
const perfEnabled = isSandboxStreamPerfEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (!perfEnabled || !follow) return;
|
||||
let raf = 0;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
scrollToBottom("auto");
|
||||
});
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
const tick = () => {
|
||||
scrollToBottom("auto");
|
||||
};
|
||||
tick();
|
||||
const intervalId = window.setInterval(tick, SANDBOX_LEGACY_SCROLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [perfEnabled, follow, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (perfEnabled || !follow) return;
|
||||
scrollToBottom("auto");
|
||||
}, [perfEnabled, follow, contentKey, viewMode, scrollToBottom]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
|
||||
|
||||
test("resolveWriteFilePath reads the virtual URL pathname", () => {
|
||||
assert.equal(
|
||||
resolveWriteFilePath("write-file:report.md?message_id=m1&tool_call_id=t1"),
|
||||
"report.md",
|
||||
);
|
||||
assert.equal(resolveWriteFilePath("mnt/report.md"), "mnt/report.md");
|
||||
});
|
||||
|
||||
test("isWriteFileInFlight is true only until that file's tool result arrives", () => {
|
||||
const inFlight = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(isWriteFileInFlight(inFlight, "write-file:report.md?tool_call_id=w1"), true);
|
||||
|
||||
const completed = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
isWriteFileInFlight(completed, "write-file:report.md?tool_call_id=w1"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a later thread turn does not treat a finished write as in-flight", () => {
|
||||
const laterTurn = {
|
||||
messages: [
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [
|
||||
{ id: "w1", name: "write_file", args: { path: "report.md" } },
|
||||
],
|
||||
},
|
||||
{ type: "tool", tool_call_id: "w1", name: "write_file" },
|
||||
{ type: "human", content: "按方案 A" },
|
||||
{
|
||||
type: "ai",
|
||||
tool_calls: [{ id: "c2", name: "web_search", args: { query: "x" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
isWriteFileInFlight(laterTurn, "write-file:report.md?tool_call_id=w1"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
export function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(filepath);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
return !isImageFile(path);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWriteFilePath(filepath: string): string {
|
||||
if (filepath.startsWith("write-file:")) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(filepath).pathname).trim();
|
||||
} catch {
|
||||
const rest = filepath.slice("write-file:".length);
|
||||
return rest.split(/[?#]/, 1)[0]?.trim() ?? filepath;
|
||||
}
|
||||
}
|
||||
return filepath.trim();
|
||||
}
|
||||
|
||||
type WriteFileThreadLike = {
|
||||
messages: Array<{
|
||||
type?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
function hasToolResult(thread: WriteFileThreadLike, toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
return thread.messages.some(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
/** True only while THIS file's write_file call has not yet returned a tool result. */
|
||||
export function isWriteFileInFlight(
|
||||
thread: WriteFileThreadLike,
|
||||
filepath: string,
|
||||
): boolean {
|
||||
const path = resolveWriteFilePath(filepath);
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
for (const message of thread.messages) {
|
||||
if (message.type !== "ai" || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall.name !== "write_file") {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as { path?: unknown } | undefined;
|
||||
const callPath = typeof args?.path === "string" ? args.path.trim() : "";
|
||||
if (callPath !== path) {
|
||||
continue;
|
||||
}
|
||||
if (!hasToolResult(thread, toolCall.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* embed-session.ts 浏览上下文隔离单测。
|
||||
* 运行方式(项目无 vitest):
|
||||
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
|
||||
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
|
||||
* node --test .tmp-tests/embed-session.test.mjs
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isChromeHidden,
|
||||
isEmbedActive,
|
||||
isIsolatedSession,
|
||||
readEmbedSession,
|
||||
syncEmbedSessionFromSearch,
|
||||
} from "./embed-session";
|
||||
|
||||
class MemoryStorage {
|
||||
#map = new Map<string, string>();
|
||||
getItem(key: string): string | null {
|
||||
return this.#map.has(key) ? this.#map.get(key)! : null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.#map.set(key, String(value));
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.#map.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
type FakeWindow = {
|
||||
self: FakeWindow;
|
||||
top: FakeWindow;
|
||||
sessionStorage: MemoryStorage;
|
||||
location: { hash: string; search: string };
|
||||
__deerflowTopChromeEmbed?: boolean;
|
||||
};
|
||||
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
function withSharedTab<T>(run: (ctx: {
|
||||
storage: MemoryStorage;
|
||||
asTop: () => void;
|
||||
asIframe: () => void;
|
||||
}) => T): T {
|
||||
const storage = new MemoryStorage();
|
||||
const top = {
|
||||
sessionStorage: storage,
|
||||
location: { hash: "", search: "" },
|
||||
} as FakeWindow;
|
||||
top.self = top;
|
||||
top.top = top;
|
||||
const iframe = {
|
||||
sessionStorage: storage,
|
||||
location: { hash: "#/login/guest?embed=1&theme=blue&hideJump=0", search: "" },
|
||||
} as FakeWindow;
|
||||
iframe.self = iframe;
|
||||
iframe.top = top;
|
||||
|
||||
const asTop = () => {
|
||||
(globalThis as { window: FakeWindow }).window = top;
|
||||
};
|
||||
const asIframe = () => {
|
||||
(globalThis as { window: FakeWindow }).window = iframe;
|
||||
};
|
||||
|
||||
asTop();
|
||||
try {
|
||||
return run({ storage, asTop, asIframe });
|
||||
} finally {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window: unknown }).window = originalWindow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("nested embed=1 does not hide chrome on the same-origin top window", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(isChromeHidden(), true);
|
||||
assert.ok(storage.getItem("embed.session.nested"));
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("top parent reclaims pollution from an old nested iframe (no topOwned)", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("");
|
||||
// 模拟未升级的老版内层:仍写共用键且没有 topOwned。
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// 外层点菜单重算布局时会读 session —— 必须立刻自愈。
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("top sync after old nested pollution does not mark owner", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe reclaims a polluted shared embed.session key", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
}),
|
||||
);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isChromeHidden(), false);
|
||||
assert.equal(storage.getItem("embed.session"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("nested iframe keeps the top window's own embed session", () => {
|
||||
withSharedTab(({ asTop, asIframe }) => {
|
||||
asTop();
|
||||
syncEmbedSessionFromSearch("?embed=1&theme=dark");
|
||||
syncEmbedSessionFromSearch("");
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(readEmbedSession()?.topOwned, true);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), true);
|
||||
assert.equal(readEmbedSession()?.theme, "dark");
|
||||
});
|
||||
});
|
||||
|
||||
test("nested reclaim strips embed but keeps top isolate", () => {
|
||||
withSharedTab(({ storage, asTop, asIframe }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: true,
|
||||
}),
|
||||
);
|
||||
|
||||
asIframe();
|
||||
syncEmbedSessionFromSearch("?embed=1");
|
||||
|
||||
asTop();
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test("top reclaim strips embed but keeps isolate without nested help", () => {
|
||||
withSharedTab(({ storage, asTop }) => {
|
||||
asTop();
|
||||
storage.setItem(
|
||||
"embed.session",
|
||||
JSON.stringify({
|
||||
embed: true,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: true,
|
||||
}),
|
||||
);
|
||||
assert.equal(isEmbedActive(), false);
|
||||
assert.equal(isIsolatedSession(), true);
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,54 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
|
||||
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
|
||||
* localStorage 偏好。
|
||||
*
|
||||
* iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」;
|
||||
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
|
||||
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
|
||||
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
|
||||
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
|
||||
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
|
||||
* 路由看起来却没变。因此:
|
||||
* - 顶层窗口写 `embed.session`,嵌套 iframe(新版)写 `embed.session.nested`;
|
||||
* - 顶层只信任带 `topOwned` 的会话(本窗自己从 URL 置位);老版嵌套页仍写共用键时,
|
||||
* 外层父页会在读取/同步时清掉无主污染,即使内层未升级也能自愈。
|
||||
*/
|
||||
|
||||
const EMBED_SESSION_KEY = "embed.session";
|
||||
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
|
||||
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
|
||||
/**
|
||||
* 顶层窗口认领「本页自己的去 chrome 会话」的内存标记(配合 session 里的 `topOwned`)。
|
||||
* 嵌套同源 iframe 看到此标记就不会清掉顶层合法的 embed/hideChrome 持久化。
|
||||
*/
|
||||
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
|
||||
|
||||
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
|
||||
|
||||
function getWindow(): TopChromeOwnerWindow | undefined {
|
||||
if (typeof globalThis === "undefined") return undefined;
|
||||
return (globalThis as { window?: TopChromeOwnerWindow }).window;
|
||||
}
|
||||
|
||||
function getSessionStorage(): Storage | null {
|
||||
try {
|
||||
return getWindow()?.sessionStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
|
||||
function isTopLevelWindow(): boolean {
|
||||
const w = getWindow();
|
||||
if (!w) return true;
|
||||
try {
|
||||
return w.self === w.top;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function embedSessionKey(): string {
|
||||
return isTopLevelWindow() ? EMBED_SESSION_KEY : EMBED_SESSION_NESTED_KEY;
|
||||
}
|
||||
|
||||
export type EmbedTheme = "light" | "dark";
|
||||
|
||||
@@ -53,6 +96,154 @@ export interface EmbedSession {
|
||||
hideChrome: boolean;
|
||||
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
|
||||
isolate: boolean;
|
||||
/**
|
||||
* 仅顶层窗口自己从 URL 置位去 chrome 时写入。缺省/false 表示可能是同源嵌套 iframe
|
||||
* (含未升级的老版内层)写脏的共用键,顶层父页应忽略并清掉。
|
||||
*/
|
||||
topOwned?: boolean;
|
||||
}
|
||||
|
||||
function parseEmbedSession(raw: string | null): EmbedSession | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const p = JSON.parse(raw) as Partial<EmbedSession>;
|
||||
return {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
topOwned: p.topOwned === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHidesChrome(session: EmbedSession | null | undefined): boolean {
|
||||
return (
|
||||
session?.embed === true ||
|
||||
session?.hideChrome === true ||
|
||||
session?.hideHeader === true ||
|
||||
session?.hideJump === true
|
||||
);
|
||||
}
|
||||
|
||||
function emptySession(): EmbedSession {
|
||||
return {
|
||||
embed: false,
|
||||
theme: "light",
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function stripChromeFlags(session: EmbedSession): EmbedSession {
|
||||
return {
|
||||
...session,
|
||||
embed: false,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
topOwned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function persistTopSession(next: EmbedSession): void {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(EMBED_SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
hideHeader: next.hideHeader,
|
||||
hideChrome: next.hideChrome,
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
store.setItem(EMBED_SESSION_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function isTopChromeOwned(session?: EmbedSession | null): boolean {
|
||||
const w = getWindow();
|
||||
if (w?.[TOP_CHROME_OWNER_FLAG] === true) return true;
|
||||
return session?.topOwned === true;
|
||||
}
|
||||
|
||||
/** 顶层窗口从自己的 URL 置位去 chrome 后打标(内存 + session.topOwned)。 */
|
||||
function markTopChromeOwner(session: EmbedSession): void {
|
||||
const w = getWindow();
|
||||
if (!w || !isTopLevelWindow()) return;
|
||||
if (!sessionHidesChrome(session) || !session.topOwned) return;
|
||||
w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层父页自愈:老版同源嵌套 iframe 仍会写共用 `embed.session`(无 `topOwned`)。
|
||||
* 若本窗从未从自己的 URL 认领过嵌入态,则清掉去 chrome 标志,保留 isolate。
|
||||
*/
|
||||
function reclaimUnownedTopChrome(): void {
|
||||
if (!isTopLevelWindow()) return;
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur || !sessionHidesChrome(cur)) return;
|
||||
if (isTopChromeOwned(cur)) {
|
||||
const w = getWindow();
|
||||
if (w) w[TOP_CHROME_OWNER_FLAG] = true;
|
||||
return;
|
||||
}
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
|
||||
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
|
||||
*/
|
||||
function migrateLegacySharedSessionIfNested(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const store = getSessionStorage();
|
||||
if (!store || store.getItem(EMBED_SESSION_NESTED_KEY)) return;
|
||||
const legacy = store.getItem(EMBED_SESSION_KEY);
|
||||
if (legacy) store.setItem(EMBED_SESSION_NESTED_KEY, legacy);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function reclaimPollutedTopChrome(): void {
|
||||
if (isTopLevelWindow()) return;
|
||||
try {
|
||||
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
|
||||
if (!cur) return;
|
||||
// 顶层已认领(内存标记或 session.topOwned)则不动。
|
||||
if (top?.[TOP_CHROME_OWNER_FLAG] === true || cur.topOwned === true) return;
|
||||
persistTopSession(stripChromeFlags(cur));
|
||||
} catch {
|
||||
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
|
||||
}
|
||||
}
|
||||
|
||||
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
|
||||
@@ -101,20 +292,21 @@ export function readEmbedThemeParam(search: string): string {
|
||||
return (toSearchParams(search).get("theme") ?? "").trim();
|
||||
}
|
||||
|
||||
/** 读本标签页的嵌入会话;无 → null。 */
|
||||
function searchEnablesChromeHide(search: string): boolean {
|
||||
return (
|
||||
readEmbedParam(search) === "on" ||
|
||||
readHideChromeParam(search) === "on" ||
|
||||
readHideHeaderParam(search) === "on" ||
|
||||
readHideJumpParam(search) === "on"
|
||||
);
|
||||
}
|
||||
|
||||
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
|
||||
export function readEmbedSession(): EmbedSession | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(EMBED_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as Partial<EmbedSession>;
|
||||
return {
|
||||
embed: p.embed === true,
|
||||
theme: p.theme === "dark" ? "dark" : "light",
|
||||
hideJump: p.hideJump === true,
|
||||
hideHeader: p.hideHeader === true,
|
||||
hideChrome: p.hideChrome === true,
|
||||
isolate: p.isolate === true,
|
||||
};
|
||||
// 顶层每次读取前清掉老版内层写脏的无主 embed,保证点菜单重算布局时不会去 chrome。
|
||||
reclaimUnownedTopChrome();
|
||||
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -123,11 +315,29 @@ export function readEmbedSession(): EmbedSession | null {
|
||||
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
|
||||
function writeEmbedSession(next: EmbedSession): void {
|
||||
try {
|
||||
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
|
||||
sessionStorage.removeItem(EMBED_SESSION_KEY);
|
||||
const store = getSessionStorage();
|
||||
if (!store) return;
|
||||
const key = embedSessionKey();
|
||||
if (
|
||||
!next.embed &&
|
||||
!next.hideJump &&
|
||||
!next.hideHeader &&
|
||||
!next.hideChrome &&
|
||||
!next.isolate
|
||||
) {
|
||||
store.removeItem(key);
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
|
||||
const payload: EmbedSession = {
|
||||
embed: next.embed,
|
||||
theme: next.theme,
|
||||
hideJump: next.hideJump,
|
||||
hideHeader: next.hideHeader,
|
||||
hideChrome: next.hideChrome,
|
||||
isolate: next.isolate,
|
||||
};
|
||||
if (next.topOwned) payload.topOwned = true;
|
||||
store.setItem(key, JSON.stringify(payload));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
@@ -180,18 +390,15 @@ export function isIsolatedSession(): boolean {
|
||||
* 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。
|
||||
* **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时,
|
||||
* 之前置位的开关不会被抹掉。
|
||||
*
|
||||
* 顶层额外规则:只有本窗 URL 显式开过嵌套开关时才把会话标为 `topOwned`;未认领的
|
||||
* chrome 标志一律丢弃(挡住老版同源嵌套 iframe 对共用键的污染)。
|
||||
*/
|
||||
export function syncEmbedSessionFromSearch(search: string): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
migrateLegacySharedSessionIfNested();
|
||||
if (isTopLevelWindow()) reclaimUnownedTopChrome();
|
||||
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
const next: EmbedSession = { ...cur };
|
||||
|
||||
const embedParam = readEmbedParam(search);
|
||||
@@ -218,7 +425,20 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
if (isolateParam === "on") next.isolate = true;
|
||||
else if (isolateParam === "off") next.isolate = false;
|
||||
|
||||
if (isTopLevelWindow()) {
|
||||
if (searchEnablesChromeHide(search)) {
|
||||
next.topOwned = true;
|
||||
} else if (!isTopChromeOwned(next)) {
|
||||
// 未认领:丢掉可能由老版内层写脏的 chrome 标志,保留 isolate。
|
||||
Object.assign(next, stripChromeFlags(next), { isolate: next.isolate });
|
||||
} else {
|
||||
next.topOwned = true;
|
||||
}
|
||||
}
|
||||
|
||||
writeEmbedSession(next);
|
||||
if (isTopLevelWindow()) markTopChromeOwner(next);
|
||||
else reclaimPollutedTopChrome();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,8 +447,9 @@ export function syncEmbedSessionFromSearch(search: string): void {
|
||||
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
|
||||
*/
|
||||
function currentHashSearch(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const hash = window.location.hash || "";
|
||||
const w = getWindow();
|
||||
if (!w) return "";
|
||||
const hash = w.location?.hash || "";
|
||||
const i = hash.indexOf("?");
|
||||
return i >= 0 ? hash.slice(i) : "";
|
||||
}
|
||||
@@ -259,15 +480,6 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
|
||||
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
|
||||
*/
|
||||
export function setIsolatedSession(on: boolean): void {
|
||||
const cur =
|
||||
readEmbedSession() ??
|
||||
{
|
||||
embed: false,
|
||||
theme: "light" as EmbedTheme,
|
||||
hideJump: false,
|
||||
hideHeader: false,
|
||||
hideChrome: false,
|
||||
isolate: false,
|
||||
};
|
||||
const cur = readEmbedSession() ?? emptySession();
|
||||
writeEmbedSession({ ...cur, isolate: on });
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { groupMessages } from "./utils";
|
||||
|
||||
function groupTypes(messages: Message[]) {
|
||||
const types: string[] = [];
|
||||
groupMessages(messages, (group) => {
|
||||
types.push(group.type);
|
||||
return group.type;
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
test("write_file tool after 用户协助 opens a processing group instead of erroring", () => {
|
||||
const types = groupTypes([
|
||||
{ type: "human", id: "h1", content: "写一份报告" } as Message,
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", name: "ask_clarification", args: {} }],
|
||||
} as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t1",
|
||||
name: "ask_clarification",
|
||||
content: "请选择范围",
|
||||
tool_call_id: "c1",
|
||||
} as Message,
|
||||
{ type: "human", id: "h2", content: "按方案 A" } as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t2",
|
||||
name: "write_file",
|
||||
content: "OK",
|
||||
tool_call_id: "w1",
|
||||
} as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t3",
|
||||
name: "write_file",
|
||||
content: "OK",
|
||||
tool_call_id: "w2",
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, [
|
||||
"human",
|
||||
"assistant:processing",
|
||||
"assistant:clarification",
|
||||
"human",
|
||||
"assistant:processing",
|
||||
]);
|
||||
});
|
||||
|
||||
test("tool messages still attach to an open processing group", () => {
|
||||
const types = groupTypes([
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
tool_calls: [{ id: "w1", name: "write_file", args: { path: "a.md" } }],
|
||||
} as Message,
|
||||
{
|
||||
type: "tool",
|
||||
id: "t1",
|
||||
name: "write_file",
|
||||
content: "OK",
|
||||
tool_call_id: "w1",
|
||||
} as Message,
|
||||
]);
|
||||
|
||||
assert.deepEqual(types, ["assistant:processing"]);
|
||||
});
|
||||
@@ -119,10 +119,15 @@ export function groupMessages<T>(
|
||||
if (open) {
|
||||
open.messages.push(message);
|
||||
} else {
|
||||
console.error(
|
||||
"Unexpected tool message outside a processing group",
|
||||
message,
|
||||
);
|
||||
// After 用户协助 the last group is human/clarification, so later
|
||||
// write_file results have nowhere to attach. Opening a processing
|
||||
// group keeps them renderable and avoids console.error(full message)
|
||||
// on every MessageList render (that froze the SSE thread).
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:processing",
|
||||
messages: [message],
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
buildSentimentAgentHeaders,
|
||||
readSentimentAgentEndpoint,
|
||||
} from "./config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { readSentimentAgentEndpoint } from "./config";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
|
||||
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
|
||||
* 舆情分析智能体走 Gateway 反向代理(POST,SSE 返回):
|
||||
* 前端把 runtime-config.js 的 apiUrl / Basic Auth 随 AG-UI 体传给
|
||||
* `/api/sentiment-agent/stream`,由后台转发上游(不校验证书、不拦地址)。
|
||||
* 请求体业务字段仍为 { threadId, runId, state, tools, context, forwardedProps, messages },
|
||||
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
|
||||
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
|
||||
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
|
||||
@@ -91,6 +92,28 @@ export interface RunSentimentAgentOptions {
|
||||
handlers: SentimentAgentRunHandlers;
|
||||
}
|
||||
|
||||
/** 把 Gateway / FastAPI 错误体展开成可展示的完整字符串。 */
|
||||
async function readProxyError(response: Response): Promise<string> {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return `舆情分析服务请求失败(HTTP ${response.status})。`;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown };
|
||||
if (typeof parsed.detail === "string" && parsed.detail.trim()) {
|
||||
return parsed.detail;
|
||||
}
|
||||
if (parsed.detail != null) {
|
||||
return typeof parsed.detail === "object"
|
||||
? JSON.stringify(parsed.detail, null, 2)
|
||||
: String(parsed.detail);
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 时把原始响应原文抛给调用方。
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
|
||||
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
|
||||
@@ -99,16 +122,22 @@ export async function runSentimentAgent(
|
||||
options: RunSentimentAgentOptions,
|
||||
): Promise<void> {
|
||||
const { threadId, runId, messages, signal, handlers } = options;
|
||||
const { apiUrl } = readSentimentAgentEndpoint();
|
||||
if (!apiUrl) {
|
||||
const endpoint = readSentimentAgentEndpoint();
|
||||
if (!endpoint.apiUrl) {
|
||||
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/sentiment-agent/stream`, {
|
||||
method: "POST",
|
||||
headers: buildSentimentAgentHeaders(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
apiUrl: endpoint.apiUrl,
|
||||
authUsername: endpoint.authUsername,
|
||||
authPassword: endpoint.authPassword,
|
||||
threadId,
|
||||
runId,
|
||||
state: {},
|
||||
@@ -119,7 +148,7 @@ export async function runSentimentAgent(
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
|
||||
throw new Error(await readProxyError(response));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("舆情分析服务未返回流式响应。");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
|
||||
* 舆情分析智能体(经 Gateway 转发的虚拟智能体)的接入配置。
|
||||
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
|
||||
* dist/runtime-config.js 切换环境,无需重新打包。
|
||||
* dist/runtime-config.js 切换环境,无需重新打包。前端把这三项随请求传给后台转发上游 SSE。
|
||||
*/
|
||||
|
||||
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
|
||||
@@ -35,25 +35,3 @@ export function isSentimentAgentVisible(): boolean {
|
||||
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
|
||||
}
|
||||
|
||||
function toBase64(value: string): string {
|
||||
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
|
||||
export function buildSentimentAgentHeaders(): Record<string, string> {
|
||||
const { authUsername, authPassword } = readSentimentAgentEndpoint();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
if (authUsername && authPassword) {
|
||||
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = "workspace.sentiment-agent.";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
|
||||
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
|
||||
* 不创建后端 thread、不进 LangGraph —— 问答经 Gateway 转发外部 AG-UI 网关。
|
||||
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
|
||||
*/
|
||||
export function useSentimentAgentChat(threadId: string) {
|
||||
|
||||
@@ -24,6 +24,12 @@ import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
import {
|
||||
collectHumanMessageIds,
|
||||
hasServerEchoedOptimisticHuman,
|
||||
mergeStreamDisplayValues,
|
||||
shouldDiscardTerminalOverlay,
|
||||
} from "./stream-display";
|
||||
import {
|
||||
isAbortLikeError,
|
||||
isTransientStreamTransportError,
|
||||
@@ -178,6 +184,61 @@ function getRunMetadataStorage(): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
|
||||
* but **deletes that key on stream onSuccess**. A premature SSE close
|
||||
* (proxy timeout / false-positive disconnect) looks like success, so the
|
||||
* reconnect key vanishes while the backend run is still occupying the
|
||||
* thread. We keep a second key the SDK never touches and only clear it
|
||||
* after ``runs.get`` confirms a terminal status.
|
||||
*/
|
||||
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
|
||||
|
||||
function persistActiveRunId(threadId: string, runId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const normalized = normalizeStoredRunId(runId);
|
||||
if (!normalized) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(`lg:stream:${threadId}`, normalized);
|
||||
window.sessionStorage.setItem(
|
||||
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
|
||||
normalized,
|
||||
);
|
||||
} catch {
|
||||
/* private-mode / quota */
|
||||
}
|
||||
}
|
||||
|
||||
function readActiveRunId(threadId: string): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return (
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`),
|
||||
) ||
|
||||
normalizeStoredRunId(window.sessionStorage.getItem(`lg:stream:${threadId}`))
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveRunId(threadId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
|
||||
window.sessionStorage.removeItem(
|
||||
`${ACTIVE_RUN_STORAGE_PREFIX}${threadId}`,
|
||||
);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function isInflightRunStatus(status: string | undefined): boolean {
|
||||
return status === "pending" || status === "running";
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式停止某会话「服务端正在跑」的 run。
|
||||
*
|
||||
@@ -192,9 +253,10 @@ export async function stopThreadRun(
|
||||
if (!threadId || typeof window === "undefined") return;
|
||||
let runId: string | null = null;
|
||||
try {
|
||||
runId = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
);
|
||||
runId =
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
) || readActiveRunId(threadId);
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
@@ -204,11 +266,7 @@ export async function stopThreadRun(
|
||||
} catch {
|
||||
/* 已结束 / 不存在 / 网络错误等忽略 */
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
clearActiveRunId(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,13 +280,27 @@ export async function stopActiveThreadBeforeClarificationReply(
|
||||
let storedRunId: string | null = null;
|
||||
if (threadId && typeof window !== "undefined") {
|
||||
try {
|
||||
storedRunId = normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
);
|
||||
storedRunId =
|
||||
normalizeStoredRunId(
|
||||
window.sessionStorage.getItem(`lg:stream:${threadId}`),
|
||||
) || readActiveRunId(threadId);
|
||||
} catch {
|
||||
storedRunId = null;
|
||||
}
|
||||
}
|
||||
// 协助卡出现时图已经 jump_to=end,run 多半已是 terminal。再 stop()/cancel
|
||||
// 会再触发 onFinish → getState overlay,把刚发出的用户回复冻住、整段攒着出。
|
||||
if (storedRunId && threadId) {
|
||||
try {
|
||||
const run = await getAPIClient().runs.get(threadId, storedRunId);
|
||||
if (run && !isInflightRunStatus(run.status)) {
|
||||
clearActiveRunId(threadId);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 404 / 网络:按下面的 best-effort stop 走 */
|
||||
}
|
||||
}
|
||||
if (!thread.isLoading && !storedRunId) return;
|
||||
try {
|
||||
await stopThreadRun(threadId);
|
||||
@@ -511,6 +583,32 @@ export function useThreadStream({
|
||||
const streamReconciliationInFlightRef = useRef(false);
|
||||
const streamReconnectAttemptsRef = useRef(0);
|
||||
const streamReconnectFailureToastShownRef = useRef(false);
|
||||
const activeRunIdRef = useRef<string | null>(
|
||||
threadId && typeof window !== "undefined" ? readActiveRunId(threadId) : null,
|
||||
);
|
||||
const confirmOrRejoinRef = useRef<
|
||||
(reason: "finish" | "error" | "poll" | "conflict") => Promise<
|
||||
"terminal" | "rejoined" | "unknown"
|
||||
>
|
||||
>(async () => "unknown");
|
||||
// Keep the send button in "stop" until we confirm the backend run is
|
||||
// terminal. A premature SSE close must not look like the turn ended.
|
||||
const [streamHoldOpen, setStreamHoldOpen] = useState(
|
||||
() =>
|
||||
Boolean(
|
||||
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
|
||||
),
|
||||
);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const sendEpochRef = useRef(0);
|
||||
// Filled after useStream; onFinish/onError may read it asynchronously.
|
||||
const threadStreamRef = useRef<{
|
||||
isLoading: boolean;
|
||||
stop: () => void | Promise<void>;
|
||||
joinStream: (runId: string) => Promise<unknown>;
|
||||
messages: Message[];
|
||||
values?: AgentThreadState;
|
||||
} | null>(null);
|
||||
|
||||
const markStreamActivity = useCallback(() => {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
@@ -564,9 +662,6 @@ export function useThreadStream({
|
||||
const runMetadataStorageRef = useRef<
|
||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||
>(undefined);
|
||||
const joinStreamRef = useRef<(runId: string) => Promise<unknown>>(
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
@@ -586,6 +681,11 @@ export function useThreadStream({
|
||||
onCreated(meta) {
|
||||
handleStreamStart(meta.thread_id);
|
||||
setOnStreamThreadId(meta.thread_id);
|
||||
if (meta.run_id) {
|
||||
activeRunIdRef.current = meta.run_id;
|
||||
persistActiveRunId(meta.thread_id, meta.run_id);
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
const metadataAgentId = context.agent_id ?? context.agent_name;
|
||||
const extraMetadata =
|
||||
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
|
||||
@@ -731,21 +831,9 @@ export function useThreadStream({
|
||||
// A dropped SSE while the backend run is still alive: rejoin instead of
|
||||
// toasting "Failed to fetch" / 网络波动 and leaving the answer half-done.
|
||||
if (isTransientStreamTransportError(message)) {
|
||||
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
const runId = staleThreadId
|
||||
? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`)
|
||||
: null;
|
||||
if (
|
||||
runId &&
|
||||
streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
void joinStreamRef.current(runId).catch(() => {
|
||||
// Reconciliation will retry while the run stays non-terminal.
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("error");
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages([]);
|
||||
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
|
||||
@@ -754,22 +842,22 @@ export function useThreadStream({
|
||||
// silently — there is no live run to rejoin.
|
||||
if (isRunNotFoundError(message)) {
|
||||
const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
if (staleThreadId && runMetadataStorageRef.current) {
|
||||
runMetadataStorageRef.current.removeItem(
|
||||
`lg:stream:${staleThreadId}`,
|
||||
);
|
||||
if (staleThreadId) {
|
||||
clearActiveRunId(staleThreadId);
|
||||
}
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
return;
|
||||
}
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// A "thread not found" 404 means this route cannot accept another turn.
|
||||
// Move to a fresh chat without a full browser reload; the old reload path
|
||||
// made every click look like a crash and could loop indefinitely when a
|
||||
// different tab's cookie kept overriding this tab's Bearer token.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
@@ -780,22 +868,53 @@ export function useThreadStream({
|
||||
: iframeMatch
|
||||
? "/page/workspace/chats_iframe/new"
|
||||
: "/page/workspace/chats/new";
|
||||
navigate(target, { replace: true });
|
||||
navigate(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
|
||||
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
if (isActiveRunConflictError(message)) {
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("conflict").then((result) => {
|
||||
toast.info(
|
||||
result === "rejoined"
|
||||
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
|
||||
: friendly,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.info(friendly);
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
markStreamActivity();
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
// Do not trust a clean SSE close as "the run is done". streamResumable
|
||||
// chats keep the backend run alive on disconnect; the SDK still fires
|
||||
// onFinish and would otherwise restore the send button.
|
||||
//
|
||||
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮
|
||||
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would
|
||||
// steal the live SSE and dump tokens only at the end.
|
||||
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("finish").then((result) => {
|
||||
if (result === "rejoined") {
|
||||
return;
|
||||
}
|
||||
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
setStreamHoldOpen(false);
|
||||
listeners.current.onFinish?.(state.values);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -805,7 +924,6 @@ export function useThreadStream({
|
||||
const [threadValueOverrides, setThreadValueOverrides] = useState<
|
||||
Partial<AgentThreadState>
|
||||
>({});
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Carries the ask time of an in-flight send until the server-assigned human
|
||||
// message id shows up, so we can bind the timestamp to the real id (see the
|
||||
// reconciliation effect below). ``known`` is the set of human ids that
|
||||
@@ -813,15 +931,192 @@ export function useThreadStream({
|
||||
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
|
||||
null,
|
||||
);
|
||||
// Human ids known at optimistic-send time; used to clear optimistic only when
|
||||
// a *new* server human appears (not when length bumps from tool/checkpoint).
|
||||
const optimisticKnownHumanIdsRef = useRef<Set<string>>(new Set());
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||
// an in-flight stream without re-running on every render that produces a
|
||||
// new `thread` identity.
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
joinStreamRef.current = (runId: string) => thread.joinStream(runId);
|
||||
|
||||
const confirmOrRejoin = useCallback(
|
||||
async (
|
||||
reason: "finish" | "error" | "poll" | "conflict",
|
||||
): Promise<"terminal" | "rejoined" | "unknown"> => {
|
||||
const tid = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
if (!tid) {
|
||||
setStreamHoldOpen(false);
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (streamReconciliationInFlightRef.current && reason === "poll") {
|
||||
return "unknown";
|
||||
}
|
||||
// Stale finish/error from a prior interrupt must not joinStream over a
|
||||
// turn the user already started (协助卡提交后的实时流会被打断并攒批)。
|
||||
if (
|
||||
(reason === "finish" || reason === "error") &&
|
||||
(sendInFlightRef.current || threadStreamRef.current?.isLoading)
|
||||
) {
|
||||
return "rejoined";
|
||||
}
|
||||
streamReconciliationInFlightRef.current = true;
|
||||
const epochAtStart = sendEpochRef.current;
|
||||
try {
|
||||
const client = getAPIClient(isMock);
|
||||
let runId = activeRunIdRef.current ?? readActiveRunId(tid);
|
||||
const terminalRunId = runId;
|
||||
|
||||
const applyTerminalState = async (status: string) => {
|
||||
const finalState = await client.threads.getState(tid);
|
||||
if (
|
||||
shouldDiscardTerminalOverlay({
|
||||
epochAtStart,
|
||||
currentEpoch: sendEpochRef.current,
|
||||
sendInFlight: sendInFlightRef.current,
|
||||
terminalRunId,
|
||||
currentRunId: activeRunIdRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearActiveRunId(tid);
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
streamReconnectFailureToastShownRef.current = false;
|
||||
setThreadValueOverrides(
|
||||
finalState.values as Partial<AgentThreadState>,
|
||||
);
|
||||
if (status === "success") {
|
||||
setOptimisticMessages([]);
|
||||
if (reason !== "finish") {
|
||||
listeners.current.onFinish?.(
|
||||
finalState.values as AgentThreadState,
|
||||
);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["threads", "search"],
|
||||
});
|
||||
}
|
||||
} else if (reason !== "finish") {
|
||||
toast.error(
|
||||
"本轮生成未能完成,已同步当前对话内容,可重新发起请求。",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (runId) {
|
||||
try {
|
||||
const run = await client.runs.get(tid, runId);
|
||||
if (TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||
await applyTerminalState(run.status);
|
||||
return "terminal";
|
||||
}
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
try {
|
||||
const runs = await client.runs.list(tid, { limit: 20 });
|
||||
const inflight = runs.find((item) =>
|
||||
isInflightRunStatus(item.status),
|
||||
);
|
||||
runId = inflight?.run_id ?? null;
|
||||
if (runId) {
|
||||
activeRunIdRef.current = runId;
|
||||
}
|
||||
} catch {
|
||||
runId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
setStreamHoldOpen(false);
|
||||
activeRunIdRef.current = null;
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
persistActiveRunId(tid, runId);
|
||||
setStreamHoldOpen(true);
|
||||
|
||||
const streamAboutToEnd = reason === "finish" || reason === "error";
|
||||
if (
|
||||
threadStreamRef.current?.isLoading &&
|
||||
!streamAboutToEnd &&
|
||||
reason !== "conflict"
|
||||
) {
|
||||
if (
|
||||
Date.now() - lastStreamActivityAtRef.current <
|
||||
STREAM_RECONNECT_IDLE_MS
|
||||
) {
|
||||
return "rejoined";
|
||||
}
|
||||
// Local SSE reader stalled while the backend run is still alive.
|
||||
// SDK stop() cancels the server run if lg:stream is set — hide it
|
||||
// first, abort only the browser reader, then join the live run.
|
||||
if (reason === "poll") {
|
||||
if (
|
||||
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error(
|
||||
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
|
||||
);
|
||||
}
|
||||
return "rejoined";
|
||||
}
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
const stream = threadStreamRef.current;
|
||||
if (!stream) {
|
||||
return "unknown";
|
||||
}
|
||||
runMetadataStorageRef.current?.removeItem(`lg:stream:${tid}`);
|
||||
void Promise.resolve(stream.stop())
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (threadIdRef.current !== tid) return;
|
||||
persistActiveRunId(tid, runId);
|
||||
void stream.joinStream(runId).catch(() => undefined);
|
||||
});
|
||||
return "rejoined";
|
||||
}
|
||||
}
|
||||
|
||||
if (reason !== "conflict") {
|
||||
if (
|
||||
streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS
|
||||
) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error(
|
||||
"回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果,或点“停止”后重新提问。",
|
||||
);
|
||||
}
|
||||
return "rejoined";
|
||||
}
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
}
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
|
||||
const stream = threadStreamRef.current;
|
||||
if (!stream) {
|
||||
return "unknown";
|
||||
}
|
||||
void stream.joinStream(runId).catch(() => {
|
||||
/* poll retries while the run stays non-terminal */
|
||||
});
|
||||
return "rejoined";
|
||||
} finally {
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[isMock, onStreamThreadId, queryClient],
|
||||
);
|
||||
confirmOrRejoinRef.current = confirmOrRejoin;
|
||||
|
||||
useEffect(() => {
|
||||
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||
@@ -830,121 +1125,24 @@ export function useThreadStream({
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const reconcileRun = async () => {
|
||||
if (
|
||||
disposed ||
|
||||
streamReconciliationInFlightRef.current ||
|
||||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
|
||||
) {
|
||||
if (disposed) return;
|
||||
const dropped = !threadStreamRef.current?.isLoading;
|
||||
const idleFor = Date.now() - lastStreamActivityAtRef.current;
|
||||
if (!dropped && idleFor < STREAM_RECONCILIATION_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = runMetadataStorageRef.current;
|
||||
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
|
||||
if (!runId) return;
|
||||
|
||||
streamReconciliationInFlightRef.current = true;
|
||||
try {
|
||||
const client = getAPIClient(isMock);
|
||||
const run = await client.runs.get(activeThreadId, runId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
|
||||
// ``useStream`` retries one failed connection on its own. If that
|
||||
// reader quietly stalls, however, its internal queue never gets a
|
||||
// terminal error and the page remains frozen despite the backend
|
||||
// continuing the run.
|
||||
//
|
||||
// SDK ``stop()`` is *not* a local-only abort: its onStop hook calls
|
||||
// ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is
|
||||
// exactly "答一半就不答了". Hide the run id first so stop() only
|
||||
// aborts the browser reader, then join the still-running server run.
|
||||
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
|
||||
if (!streamReconnectFailureToastShownRef.current) {
|
||||
streamReconnectFailureToastShownRef.current = true;
|
||||
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
streamReconnectAttemptsRef.current += 1;
|
||||
// Avoid enqueueing another reconnect before this new reader has had
|
||||
// enough time to receive its first event. A genuine event resets
|
||||
// the attempt counter through ``markStreamActivity``.
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
|
||||
const stream = threadStreamRef.current;
|
||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||
void Promise.resolve(stream.stop())
|
||||
.catch(() => {
|
||||
// Stopping a stale local reader is best-effort; joining is still
|
||||
// useful if the SDK already considers the stream stopped.
|
||||
})
|
||||
.finally(() => {
|
||||
if (disposed || threadIdRef.current !== activeThreadId) return;
|
||||
try {
|
||||
storage?.setItem(`lg:stream:${activeThreadId}`, runId);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
void stream.joinStream(runId).catch(() => {
|
||||
// The status poll will either reconcile a terminal run or
|
||||
// schedule the next bounded reconnect attempt.
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The server is already terminal, so the existing SSE listener cannot
|
||||
// receive any more useful events. Load the checkpoint directly before
|
||||
// resetting the SDK stream; this preserves the normal onFinish side
|
||||
// effects (e.g. marking a position delivery complete) even when the
|
||||
// browser missed the terminal event or already reported a transport
|
||||
// error and dropped ``isLoading``.
|
||||
const finalState = await client.threads.getState(activeThreadId);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
streamReconnectFailureToastShownRef.current = false;
|
||||
if (run.status === "success") {
|
||||
setOptimisticMessages([]);
|
||||
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
|
||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
} else {
|
||||
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
|
||||
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
|
||||
}
|
||||
|
||||
// Changing the controlled thread id clears only the local SDK stream
|
||||
// (it does not cancel the already-terminal backend run). Switching it
|
||||
// back immediately makes useStream fetch the persisted messages, so
|
||||
// the page exits the false loading state without a manual refresh.
|
||||
setOnStreamThreadId(null);
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
if (!disposed && threadIdRef.current === activeThreadId) {
|
||||
setOnStreamThreadId(activeThreadId);
|
||||
}
|
||||
}, 0);
|
||||
} catch {
|
||||
// A transient status lookup failure must not disrupt a real stream;
|
||||
// the next polling interval will try again if it is still quiet.
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
streamReconciliationInFlightRef.current = false;
|
||||
}
|
||||
if (dropped && idleFor < 800) {
|
||||
return;
|
||||
}
|
||||
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
|
||||
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!stored && !streamHoldOpen) {
|
||||
return;
|
||||
}
|
||||
await confirmOrRejoin("poll");
|
||||
};
|
||||
|
||||
const interval = window.setInterval(
|
||||
@@ -955,11 +1153,8 @@ export function useThreadStream({
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(interval);
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
};
|
||||
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
|
||||
}, [confirmOrRejoin, onStreamThreadId, streamHoldOpen, thread.isLoading]);
|
||||
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
@@ -989,7 +1184,11 @@ export function useThreadStream({
|
||||
}
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
sendEpochRef.current += 1;
|
||||
prevMsgCountRef.current = 0;
|
||||
optimisticKnownHumanIdsRef.current = new Set();
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
setOptimisticMessages([]);
|
||||
setIsUploading(false);
|
||||
setThreadValueOverrides({});
|
||||
@@ -1074,15 +1273,22 @@ export function useThreadStream({
|
||||
threadValueOverrides.title_provisional,
|
||||
]);
|
||||
|
||||
// Clear optimistic when server messages arrive (count increases)
|
||||
// Clear optimistic only when the server echoes a *new* human message.
|
||||
// After 用户协助, a late checkpoint / tool result can bump length without
|
||||
// the new answer yet — clearing then makes the bubble flash and vanish.
|
||||
useEffect(() => {
|
||||
if (optimisticMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
optimisticMessages.length > 0 &&
|
||||
thread.messages.length > prevMsgCountRef.current
|
||||
hasServerEchoedOptimisticHuman(
|
||||
thread.messages,
|
||||
optimisticKnownHumanIdsRef.current,
|
||||
)
|
||||
) {
|
||||
setOptimisticMessages([]);
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
}, [thread.messages, optimisticMessages.length]);
|
||||
|
||||
// Bind a just-sent question's ask time to the server-assigned human message
|
||||
// id once it appears. Persists to localStorage so the time shows on every
|
||||
@@ -1115,22 +1321,36 @@ export function useThreadStream({
|
||||
return;
|
||||
}
|
||||
sendInFlightRef.current = true;
|
||||
sendEpochRef.current += 1;
|
||||
// Drop frozen checkpoint messages so the new SSE can paint live tokens.
|
||||
// Keep title / title_provisional overlays from the prior reconcile.
|
||||
setThreadValueOverrides((prev) => {
|
||||
if (!("messages" in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const { messages: _dropped, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
setStreamHoldOpen(false);
|
||||
|
||||
const text = message.text.trim();
|
||||
// Wall-clock time the question was asked. Stored on the human message so
|
||||
// the chat can show "几点提问" and have it survive a reload (it round-trips
|
||||
// through the checkpoint inside additional_kwargs, same as `files`).
|
||||
const clientTs = new Date().toISOString();
|
||||
const knownHumanIds = collectHumanMessageIds(
|
||||
thread.messages,
|
||||
Array.isArray(thread.values?.messages)
|
||||
? (thread.values.messages as Message[])
|
||||
: undefined,
|
||||
);
|
||||
// Remember which human messages already exist so the reconciliation
|
||||
// effect can bind ``clientTs`` to the new one once the server returns it.
|
||||
pendingAskTimeRef.current = {
|
||||
ts: clientTs,
|
||||
known: new Set(
|
||||
thread.messages
|
||||
.filter((m) => m.type === "human" && m.id)
|
||||
.map((m) => m.id as string),
|
||||
),
|
||||
known: knownHumanIds,
|
||||
};
|
||||
optimisticKnownHumanIdsRef.current = knownHumanIds;
|
||||
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
@@ -1355,6 +1575,18 @@ export function useThreadStream({
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const friendly = friendlySendLimitMessage(message);
|
||||
if (friendly) {
|
||||
setIsUploading(false);
|
||||
if (isActiveRunConflictError(message)) {
|
||||
setOptimisticMessages([]);
|
||||
setStreamHoldOpen(true);
|
||||
const result = await confirmOrRejoin("conflict");
|
||||
toast.info(
|
||||
result === "rejoined"
|
||||
? "上一轮回答仍在生成,已重新连接当前回复。请等待结束或点“停止”后再提问。"
|
||||
: friendly,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages((messages) => {
|
||||
const human = messages.find((item) => item.type === "human");
|
||||
const kept = human ? [human] : [];
|
||||
@@ -1368,7 +1600,6 @@ export function useThreadStream({
|
||||
} as Message,
|
||||
];
|
||||
});
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
setOptimisticMessages([]);
|
||||
@@ -1378,31 +1609,58 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
const handleThreadStop = useCallback(async () => {
|
||||
const tid = threadIdRef.current;
|
||||
const runId =
|
||||
activeRunIdRef.current ?? (tid ? readActiveRunId(tid) : null);
|
||||
setStreamHoldOpen(false);
|
||||
activeRunIdRef.current = null;
|
||||
if (tid && runId) {
|
||||
persistActiveRunId(tid, runId);
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(threadStreamRef.current?.stop());
|
||||
} catch {
|
||||
/* local abort is best-effort */
|
||||
}
|
||||
if (tid && runId) {
|
||||
try {
|
||||
await getAPIClient(isMock).runs.cancel(tid, runId);
|
||||
} catch {
|
||||
/* already stopped / missing */
|
||||
}
|
||||
clearActiveRunId(tid);
|
||||
}
|
||||
}, [isMock]);
|
||||
|
||||
// Merge thread with optimistic messages for display. While a live SSE is
|
||||
// open, never let a stale getState snapshot's `messages` win — that is what
|
||||
// made 协助卡提交后「发送中」攒到结束才一口气出字。
|
||||
const mergedValues =
|
||||
Object.keys(threadValueOverrides).length > 0
|
||||
? {
|
||||
...thread.values,
|
||||
...threadValueOverrides,
|
||||
}
|
||||
: thread.values;
|
||||
mergeStreamDisplayValues(
|
||||
thread.values as AgentThreadState | undefined,
|
||||
threadValueOverrides,
|
||||
thread.isLoading,
|
||||
) ?? thread.values;
|
||||
|
||||
const mergedMessages =
|
||||
Array.isArray(mergedValues.messages)
|
||||
Array.isArray(mergedValues?.messages)
|
||||
? (mergedValues.messages as Message[])
|
||||
: thread.messages;
|
||||
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0 || mergedValues !== thread.values
|
||||
? ({
|
||||
...thread,
|
||||
values: mergedValues,
|
||||
messages: [...mergedMessages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
values: mergedValues,
|
||||
messages:
|
||||
optimisticMessages.length > 0
|
||||
? [...mergedMessages, ...optimisticMessages]
|
||||
: mergedMessages,
|
||||
isLoading: thread.isLoading || streamHoldOpen,
|
||||
stop: handleThreadStop,
|
||||
} as typeof thread;
|
||||
|
||||
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
|
||||
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
collectHumanMessageIds,
|
||||
hasServerEchoedOptimisticHuman,
|
||||
mergeStreamDisplayValues,
|
||||
shouldDiscardTerminalOverlay,
|
||||
} from "./stream-display";
|
||||
|
||||
test("live streaming ignores frozen checkpoint messages so tokens can paint", () => {
|
||||
const live = {
|
||||
title: "live",
|
||||
messages: [{ type: "human", id: "h1", content: "A" }],
|
||||
};
|
||||
const overrides = {
|
||||
title: "frozen",
|
||||
messages: [{ type: "human", id: "h1", content: "A" }],
|
||||
};
|
||||
|
||||
const duringStream = mergeStreamDisplayValues(live, overrides, true);
|
||||
assert.deepEqual(duringStream?.messages, live.messages);
|
||||
assert.equal(duringStream?.title, "frozen");
|
||||
|
||||
const afterStream = mergeStreamDisplayValues(live, overrides, false);
|
||||
assert.equal(afterStream?.title, "frozen");
|
||||
assert.deepEqual(afterStream?.messages, overrides.messages);
|
||||
});
|
||||
|
||||
test("a later send discards the previous run's getState overlay", () => {
|
||||
assert.equal(
|
||||
shouldDiscardTerminalOverlay({
|
||||
epochAtStart: 1,
|
||||
currentEpoch: 2,
|
||||
sendInFlight: false,
|
||||
terminalRunId: "old-run",
|
||||
currentRunId: "old-run",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardTerminalOverlay({
|
||||
epochAtStart: 2,
|
||||
currentEpoch: 2,
|
||||
sendInFlight: true,
|
||||
terminalRunId: "old-run",
|
||||
currentRunId: "old-run",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardTerminalOverlay({
|
||||
epochAtStart: 2,
|
||||
currentEpoch: 2,
|
||||
sendInFlight: false,
|
||||
terminalRunId: "old-run",
|
||||
currentRunId: "new-run",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldDiscardTerminalOverlay({
|
||||
epochAtStart: 1,
|
||||
currentEpoch: 1,
|
||||
sendInFlight: false,
|
||||
terminalRunId: "run-1",
|
||||
currentRunId: "run-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("optimistic human stays until a new server human id appears", () => {
|
||||
const known = collectHumanMessageIds([
|
||||
{ type: "human", id: "h1", content: "first" } as Message,
|
||||
]);
|
||||
const afterToolOnly = [
|
||||
{ type: "human", id: "h1", content: "first" },
|
||||
{ type: "tool", id: "t1", content: "OK" },
|
||||
] as Message[];
|
||||
assert.equal(hasServerEchoedOptimisticHuman(afterToolOnly, known), false);
|
||||
|
||||
const afterEcho = [
|
||||
...afterToolOnly,
|
||||
{ type: "human", id: "h2", content: "按方案 A" },
|
||||
] as Message[];
|
||||
assert.equal(hasServerEchoedOptimisticHuman(afterEcho, known), true);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
/**
|
||||
* After a run ends (especially an interrupt / 用户协助卡), `confirmOrRejoin`
|
||||
* snapshots `threads.getState()` into an overlay so a premature SSE close can
|
||||
* still show the checkpoint. That overlay must never win over a *new* live
|
||||
* stream: `overrides.messages` would freeze the previous turn and dump the
|
||||
* next answer only when the next `getState()` lands.
|
||||
*/
|
||||
export function mergeStreamDisplayValues<T extends Record<string, unknown>>(
|
||||
live: T | undefined,
|
||||
overrides: Partial<T>,
|
||||
isLiveStreaming: boolean,
|
||||
): T | undefined {
|
||||
const overrideKeys = Object.keys(overrides);
|
||||
if (overrideKeys.length === 0) {
|
||||
return live;
|
||||
}
|
||||
if (isLiveStreaming) {
|
||||
const { messages: _ignored, ...rest } = overrides as Partial<T> & {
|
||||
messages?: unknown;
|
||||
};
|
||||
if (Object.keys(rest).length === 0) {
|
||||
return live;
|
||||
}
|
||||
return { ...(live as T), ...rest };
|
||||
}
|
||||
return { ...(live as T), ...overrides };
|
||||
}
|
||||
|
||||
export function shouldDiscardTerminalOverlay(opts: {
|
||||
epochAtStart: number;
|
||||
currentEpoch: number;
|
||||
sendInFlight: boolean;
|
||||
terminalRunId: string | null;
|
||||
currentRunId: string | null;
|
||||
}): boolean {
|
||||
if (opts.currentEpoch !== opts.epochAtStart) {
|
||||
return true;
|
||||
}
|
||||
if (opts.sendInFlight) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
opts.currentRunId &&
|
||||
opts.terminalRunId &&
|
||||
opts.currentRunId !== opts.terminalRunId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectHumanMessageIds(
|
||||
...groups: Array<Message[] | undefined>
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const messages of groups) {
|
||||
if (!Array.isArray(messages)) continue;
|
||||
for (const message of messages) {
|
||||
if (
|
||||
message?.type === "human" &&
|
||||
typeof message.id === "string" &&
|
||||
message.id &&
|
||||
!message.id.startsWith("opt-")
|
||||
) {
|
||||
ids.add(message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the optimistic human bubble only when the server has echoed a *new*
|
||||
* human message. A late checkpoint / tool result that merely increases
|
||||
* `messages.length` must not make the user's input vanish.
|
||||
*/
|
||||
export function hasServerEchoedOptimisticHuman(
|
||||
serverMessages: Message[] | undefined,
|
||||
knownHumanIds: Set<string>,
|
||||
): boolean {
|
||||
if (!Array.isArray(serverMessages)) {
|
||||
return false;
|
||||
}
|
||||
for (const message of serverMessages) {
|
||||
if (
|
||||
message?.type === "human" &&
|
||||
typeof message.id === "string" &&
|
||||
message.id &&
|
||||
!message.id.startsWith("opt-") &&
|
||||
!knownHumanIds.has(message.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import {
|
||||
BotIcon,
|
||||
Compass,
|
||||
PanelRightOpenIcon,
|
||||
PlusSquare,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
@@ -11,6 +16,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { AgentChatSidebar } from "@/components/workspace/agents/agent-chat-sidebar";
|
||||
import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel";
|
||||
import { AgentQuestionChips } from "@/components/workspace/agents/agent-question-chips";
|
||||
import {
|
||||
AgentGuide,
|
||||
isAgentGuideEnvironment,
|
||||
startAgentGuide,
|
||||
} from "@/components/workspace/agents/agent-guide";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
@@ -186,6 +196,10 @@ export default function AgentChatPage({
|
||||
|
||||
// 任务深链上下文(taskId + 开场信息):闸门(rwfx)与纯绑定(3qfx/xdfx)都要读取。
|
||||
const taskCtxOn = !!(taskGate || taskBind);
|
||||
// 右侧智能体详情面板折叠态(受控):普通问答默认展开,任务工作区默认收起。
|
||||
// 收起时整列宽度动画关闭;展开态的「收起」按钮在面板右上角,收起态的
|
||||
// 「展开」按钮在顶部 header「新对话」按钮右侧。
|
||||
const [detailCollapsed, setDetailCollapsed] = useState(taskCtxOn);
|
||||
const isXdfxTaskBind = !!chatBasePath?.includes("/plan-task-action/");
|
||||
const taskContext = useMemo<TaskContext | null>(() => {
|
||||
if (!taskCtxOn) return null;
|
||||
@@ -559,6 +573,10 @@ export default function AgentChatPage({
|
||||
// 3qfx/xdfx(taskBind)不展示闸门,新建对话进问答页后由右上角「开始回答」按钮手动发出开场信息。
|
||||
const showTaskGate = !!taskGate && !!taskId && isNewThread && !hasStartedConversation;
|
||||
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
// 智能体使用引导(chat 阶段):仅普通智能体对话页 —— 排除通用问答原地嵌入
|
||||
// (embeddedAgentId)、任务深链工作区(taskGate/taskBind)等复用本页的场景。
|
||||
const agentGuideEligible =
|
||||
!!showAgentList && !isEmbedded && !taskCtxOn && isAgentGuideEnvironment();
|
||||
|
||||
const status = thread.error
|
||||
? "error"
|
||||
@@ -575,7 +593,14 @@ export default function AgentChatPage({
|
||||
panel stays rightmost. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden workspace-chat-bg">
|
||||
{/* 最左:智能体列表栏(仅独立整页问答开启)。顶部下拉切换 我的/广场。 */}
|
||||
{showAgentList && <AgentChatSidebar activeAgentId={agent_id} />}
|
||||
{showAgentList && (
|
||||
<div
|
||||
data-agent-guide="chat-sidebar"
|
||||
className="flex h-full shrink-0"
|
||||
>
|
||||
<AgentChatSidebar activeAgentId={agent_id} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ChatBox threadId={threadId}>
|
||||
{/* Main conversation column. `relative` hosts the floating
|
||||
@@ -598,6 +623,24 @@ export default function AgentChatPage({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* 智能体使用引导(chat 阶段)手动重放入口。 */}
|
||||
{agentGuideEligible && (
|
||||
<Tooltip content="重新播放智能体使用引导">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground mr-1"
|
||||
onClick={() => {
|
||||
// 引导的 chat-detail 步骤会高亮右侧面板,重放前先展开。
|
||||
setDetailCollapsed(false);
|
||||
startAgentGuide("chat");
|
||||
}}
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
使用引导
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */}
|
||||
{isTemplateBuilder && (
|
||||
<div className="mr-2 flex items-center gap-1.5">
|
||||
@@ -657,6 +700,23 @@ export default function AgentChatPage({
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* 右侧智能体详情面板收起时的「展开」入口:仅收起态显示
|
||||
(展开态的「收起」按钮在面板右上角)。桌面端专用(lg+),
|
||||
窄屏面板走抽屉交互;嵌入态面板固定渲染,不显示该开关。 */}
|
||||
{!isEmbedded && detailCollapsed && (
|
||||
<Tooltip content="展开智能体信息">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground hidden lg:inline-flex"
|
||||
onClick={() => setDetailCollapsed(false)}
|
||||
aria-label="展开智能体信息"
|
||||
aria-expanded={false}
|
||||
>
|
||||
<PanelRightOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
@@ -688,13 +748,17 @@ export default function AgentChatPage({
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
<AgentQuestionChips
|
||||
className="mb-3"
|
||||
questions={commonQuestions}
|
||||
disabled={isDemoMode}
|
||||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div data-agent-guide="chat-questions" className="mb-3">
|
||||
<AgentQuestionChips
|
||||
questions={commonQuestions}
|
||||
disabled={isDemoMode}
|
||||
onAsk={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
data-agent-guide="chat-input"
|
||||
>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
@@ -756,7 +820,10 @@ export default function AgentChatPage({
|
||||
/>
|
||||
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<div
|
||||
className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)"
|
||||
data-agent-guide="chat-input"
|
||||
>
|
||||
<TodoList
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
@@ -805,16 +872,23 @@ export default function AgentChatPage({
|
||||
</div>
|
||||
|
||||
{/* Right: agent detail panel — always the rightmost column. */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
defaultCollapsed
|
||||
showRecentChats={!taskCtxOn}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
<div
|
||||
data-agent-guide="chat-detail"
|
||||
className="flex h-full shrink-0"
|
||||
>
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
collapsed={detailCollapsed}
|
||||
onCollapsedChange={setDetailCollapsed}
|
||||
showRecentChats={!taskCtxOn}
|
||||
compactAppearanceActions={compactAppearanceActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AgentGuide stage="chat" eligible={agentGuideEligible} />
|
||||
|
||||
{/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */}
|
||||
{isTemplateBuilder && (
|
||||
|
||||
@@ -34,11 +34,13 @@ import {
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { CompactModeGuide } from "@/components/workspace/compact-mode-guide";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isEmbedActive, isHideChromeActive } from "@/core/embed/embed-session";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
@@ -665,6 +667,17 @@ export default function ChatPage() {
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
// 简洁模式新手引导:仅面向「无全局导航栏/侧边栏」的正常简洁布局。iframe 嵌入
|
||||
// (embed=1)、去 chrome(hideChrome=1)、强制收起侧栏(无界部署)等环境同样没有
|
||||
// 全局 chrome,但不该在外部系统里弹引导。
|
||||
const compactGuideEligible =
|
||||
!compactChrome &&
|
||||
!fullChatMode &&
|
||||
!isChatsIframeWorkspacePath(pathname) &&
|
||||
!compactActionsHidden &&
|
||||
!env.FORCE_COLLAPSED_SIDEBAR &&
|
||||
!isEmbedActive() &&
|
||||
!isWujieEnv();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
|
||||
@@ -801,7 +814,10 @@ export default function ChatPage() {
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<div
|
||||
className="w-full -translate-y-4"
|
||||
data-compact-guide="prompt-input"
|
||||
>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
@@ -886,6 +902,10 @@ export default function ChatPage() {
|
||||
className="top-16 right-0 bottom-48 left-0"
|
||||
/>
|
||||
)}
|
||||
<CompactModeGuide
|
||||
eligible={compactGuideEligible}
|
||||
autoStart={mounted && isLandingState}
|
||||
/>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
Compass,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
@@ -39,6 +40,11 @@ import {
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import {
|
||||
AgentGuide,
|
||||
isAgentGuideEnvironment,
|
||||
startAgentGuide,
|
||||
} from "@/components/workspace/agents/agent-guide";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
@@ -407,16 +413,28 @@ export default function NewAgentPage() {
|
||||
</Button>
|
||||
<h1 className="text-sm font-semibold">创建智能体</h1>
|
||||
</div>
|
||||
{agent ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/page/workspace/agents/${agent.id}/chats/new`)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="重新播放创建引导"
|
||||
onClick={() => startAgentGuide("create")}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
开始对话
|
||||
<Compass className="mr-1.5 h-4 w-4" />
|
||||
使用引导
|
||||
</Button>
|
||||
) : null}
|
||||
{agent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/page/workspace/agents/${agent.id}/chats/new`)}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
开始对话
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ResizablePanelGroup
|
||||
@@ -442,7 +460,10 @@ export default function NewAgentPage() {
|
||||
onSetupAgentReject={handleRejectSetupAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
|
||||
<div
|
||||
className="bg-background shrink-0 space-y-2 border-t px-4 py-4"
|
||||
data-agent-guide="create-chat-input"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
当前对话模型
|
||||
@@ -525,32 +546,35 @@ export default function NewAgentPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
placeholder="例如:合同审查智能体"
|
||||
value={nameInput}
|
||||
onChange={(event) => {
|
||||
setNameInput(event.currentTarget.value);
|
||||
setFormError("");
|
||||
}}
|
||||
className={cn(formError && "border-destructive")}
|
||||
/>
|
||||
<div className="space-y-5" data-agent-guide="create-basic">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">名称</label>
|
||||
<Input
|
||||
placeholder="例如:合同审查智能体"
|
||||
value={nameInput}
|
||||
onChange={(event) => {
|
||||
setNameInput(event.currentTarget.value);
|
||||
setFormError("");
|
||||
}}
|
||||
className={cn(formError && "border-destructive")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
placeholder="简要说明这个智能体擅长什么"
|
||||
value={descriptionInput}
|
||||
onChange={(event) =>
|
||||
setDescriptionInput(event.currentTarget.value)
|
||||
}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">描述</label>
|
||||
<Textarea
|
||||
placeholder="简要说明这个智能体擅长什么"
|
||||
value={descriptionInput}
|
||||
onChange={(event) =>
|
||||
setDescriptionInput(event.currentTarget.value)
|
||||
}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-5" data-agent-guide="create-model">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">模型</label>
|
||||
{isModelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-9 w-full items-center rounded-md border px-3 text-sm shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
@@ -590,9 +614,9 @@ export default function NewAgentPage() {
|
||||
<p className="text-muted-foreground text-xs">
|
||||
该智能体被使用时调用的模型。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">入库模板名</label>
|
||||
<Input
|
||||
placeholder="例如:临时策略库(留空表示不支持入库)"
|
||||
@@ -605,16 +629,19 @@ export default function NewAgentPage() {
|
||||
配置后,该智能体对话中生成的 Markdown
|
||||
文件右上角会出现"发布入库"按钮,可一键发送到知识入库接口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={setSelectedKnowledgeBaseIds}
|
||||
variant="panel"
|
||||
/>
|
||||
<div data-agent-guide="create-knowledge">
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={setSelectedKnowledgeBaseIds}
|
||||
variant="panel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2" data-agent-guide="create-soul">
|
||||
<label className="text-sm font-medium">SOUL.md</label>
|
||||
<Textarea
|
||||
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
||||
@@ -624,7 +651,7 @@ export default function NewAgentPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2" data-agent-guide="create-skills">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">技能</label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
@@ -675,6 +702,7 @@ export default function NewAgentPage() {
|
||||
className="w-full"
|
||||
onClick={() => void handleSaveAgent()}
|
||||
disabled={busy || isSkillsLoading}
|
||||
data-agent-guide="create-save-btn"
|
||||
>
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{agentId ? "保存智能体" : "创建智能体"}
|
||||
@@ -683,6 +711,7 @@ export default function NewAgentPage() {
|
||||
</aside>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<AgentGuide stage="create" eligible={isAgentGuideEnvironment()} />
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -30,7 +30,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体对话页:一个前端虚拟智能体(不在后端 /api/agents 落库),
|
||||
* 问答直连外部 AG-UI 网关(地址 + Basic Auth 在 runtime-config.js 配置)。
|
||||
* 问答经 Gateway POST /api/sentiment-agent/stream 转发外部 AG-UI 网关
|
||||
* (地址 + Basic Auth 在 runtime-config.js,随请求传给后台)。
|
||||
* 页面复用普通问答的消息渲染(MessageList),输入框只保留发送按钮;
|
||||
* 会话只存在当前标签页 sessionStorage,不创建后端 thread。
|
||||
*/
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function StrategyRoutes() {
|
||||
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
{/* 舆情分析虚拟智能体:前端直连外部 AG-UI 网关的专用对话页。 */}
|
||||
{/* 舆情分析虚拟智能体:经 Gateway 转发外部 AG-UI 网关的专用对话页。 */}
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats"
|
||||
element={
|
||||
|
||||
@@ -478,8 +478,8 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走前端直连外部
|
||||
AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||
{/* 舆情分析虚拟智能体:静态段优先于 :agent_id 命中,走 Gateway 转发
|
||||
外部 AG-UI 网关的专用对话页(不在后端 /api/agents 落库)。 */}
|
||||
<Route
|
||||
path="agents/sentiment-analysis/chats"
|
||||
element={
|
||||
|
||||
@@ -433,3 +433,21 @@
|
||||
--td-scrollbar-hover-color: rgba(197, 207, 224, 0.36);
|
||||
--td-scroll-track-color: #122347;
|
||||
}
|
||||
|
||||
/*
|
||||
* Guide overlays vs. Radix modal dialogs.
|
||||
*
|
||||
* Radix modal dialogs (react-remove-scroll) set `pointer-events: none` on
|
||||
* <body> while open. TDesign Guide mounts its mask / highlight / tooltip
|
||||
* directly under <body>, so they inherit `none` and every guide button
|
||||
* (上一步/下一步/跳过/完成) goes dead whenever a modal dialog is open under
|
||||
* the guide — e.g. the compact-mode tour opening the ManagePromptsDialog.
|
||||
* Re-enable pointer events on the guide layers explicitly; without a modal
|
||||
* open this matches the inherited default and changes nothing.
|
||||
*/
|
||||
.t-guide__overlay,
|
||||
.t-guide__highlight,
|
||||
.t-guide__popup,
|
||||
.t-guide__wrapper {
|
||||
pointer-events: auto;
|
||||
}
|
||||
Reference in new issue
Block a user