Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/artifacts/context.tsx
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/app.py
#	offline-backend-20260512/backend/app/gateway/deps.py
#	offline-backend-20260512/backend/app/gateway/routers/llmwiki.py
This commit is contained in:
吉鑫升 committed 2026-08-24 14:21:19 +08:00
commit e818c24769
262 files changed
+29527 -5236

No files matched your search

@@ -165,7 +165,7 @@ export const ReasoningTrigger = memo(
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
children: ReactNode;
};
export const ReasoningContent = memo(
@@ -178,7 +178,11 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
{typeof children === "string" ? (
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
) : (
children
)}
</CollapsibleContent>
),
);
@@ -0,0 +1,104 @@
import { FileOutput } from "lucide-react";
import { toast } from "sonner";
import responsibilityIcon from "@/assets/position-roundtable/责任岗位.svg";
import resultTimeIcon from "@/assets/position-roundtable/时间.svg";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import "./analysis-results.css";
import type { AnalysisResultCardItem } from "./types";
function formatResultTime(value: string | undefined, fallback: string): string {
if (!value) return fallback;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
export function AnalysisResultCardList({
items,
emptyTitle = "暂无交付产物",
emptyDescription = "第二步研讨与第三步总结生成 Markdown 后,产物会显示在这里。",
padded = true,
onOpen,
}: {
items: AnalysisResultCardItem[];
emptyTitle?: string;
emptyDescription?: string;
padded?: boolean;
onOpen: (item: AnalysisResultCardItem) => void;
}) {
if (items.length === 0) {
return (
<div className="position-roundtable-analysis-results-empty flex h-full min-h-[260px] flex-col items-center justify-center p-6 text-center">
<FileOutput className="mb-3 size-8 text-muted-foreground/50" />
<p className="text-sm font-semibold">{emptyTitle}</p>
<p className="mt-1 max-w-[240px] text-xs leading-5 text-muted-foreground">
{emptyDescription}
</p>
</div>
);
}
return (
<div className={cn("analysis-result-card-list", padded && "p-[15px]")}>
{items.map((item, index) => {
const displayedTime = formatResultTime(
item.updatedAt,
item.fallbackTime || "刚刚",
);
const displayedProductName = item.delivered ? item.productName : "未交付";
return (
<button
key={item.id}
type="button"
className="position-roundtable-analysis-result-card"
onClick={() => {
if (!item.delivered) {
toast.info(
item.unavailableMessage ||
`「${desensitize(item.agentName)}」尚未交付可预览的 Markdown 文件。`,
);
return;
}
onOpen(item);
}}
>
<div className="position-roundtable-analysis-result-card-body">
<div
className={cn(
"position-roundtable-analysis-result-tag",
`position-roundtable-analysis-result-tag--${(index % 6) + 1}`,
)}
aria-label={desensitize(item.agentName)}
>
<span>{desensitize(item.agentName)}</span>
</div>
<p className="position-roundtable-analysis-result-product">
{desensitize(displayedProductName)}
</p>
<footer className="position-roundtable-analysis-result-meta">
<span>
<img src={responsibilityIcon} alt="" aria-hidden="true" />
{desensitize(item.positionName)}
</span>
<i aria-hidden="true" />
<span>
<img src={resultTimeIcon} alt="" aria-hidden="true" />
{displayedTime}
</span>
</footer>
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,141 @@
import { useQuery } from "@tanstack/react-query";
import { FileOutput, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { ArtifactFilePreview } from "@/components/workspace/artifacts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { loadArtifactContent } from "@/core/artifacts/loader";
import { desensitize } from "@/lib/desensitize";
import { Button as TButton } from "tdesign-react";
import type { AnalysisResultCardItem } from "./types";
function isArtifactErrorContent(content: string | null | undefined): boolean {
const trimmed = content?.trim();
if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return false;
}
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const detail = parsed.detail ?? parsed.message ?? parsed.error;
return typeof detail === "string" && detail.trim().length > 0;
} catch {
return false;
}
}
function fallbackContent(item: AnalysisResultCardItem | null): string {
return (
item?.artifactContent?.trim() ||
item?.summary?.trim() ||
"暂无产物正文"
);
}
export function AnalysisResultDetailDialog({
item,
onOpenChange,
extraBody,
footerStart,
}: {
item: AnalysisResultCardItem | null;
onOpenChange: (open: boolean) => void;
extraBody?: ReactNode;
footerStart?: ReactNode;
}) {
const artifactThreadId = item?.artifactThreadId || null;
const artifactPathValue = item?.artifactPath || "";
const artifactContentQuery = useQuery({
queryKey: [
"analysis-results",
"deliverable-artifact-content",
artifactThreadId,
artifactPathValue,
],
queryFn: async () => {
if (!artifactThreadId || !artifactPathValue) return "";
const result = await loadArtifactContent({
filepath: artifactPathValue,
threadId: artifactThreadId,
isMock: false,
});
return result.content;
},
enabled: Boolean(item && artifactThreadId && artifactPathValue),
staleTime: 30_000,
});
const loadedArtifactContent =
typeof artifactContentQuery.data === "string" ? artifactContentQuery.data : "";
const loadedArtifactIsError = isArtifactErrorContent(loadedArtifactContent);
const artifactContent =
loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null;
const displayedContent = artifactContent || fallbackContent(item);
const artifactLoadFailed =
(artifactContentQuery.isError || loadedArtifactIsError) &&
!item?.artifactContent?.trim();
return (
<Dialog open={Boolean(item)} onOpenChange={onOpenChange}>
<DialogContent
overlayClassName="!z-[6000]"
className="!z-[6001] flex max-h-[86vh] w-[900px] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0"
>
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="flex items-center gap-2 text-base">
<FileOutput className="size-4 text-primary" />
交付产物详情
</DialogTitle>
</DialogHeader>
{!item ? null : (
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{desensitize(item.productName)}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{desensitize(item.agentName)} · {desensitize(item.positionName)}
</p>
</div>
{extraBody}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/65">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700 dark:text-[#e8edf5]">产物内容</p>
</div>
<div className="h-[52vh] min-h-[320px] overflow-hidden rounded-lg border border-slate-100 bg-slate-50/60 dark:border-[#2b8ffe]/20 dark:bg-[#021d3d]/70">
{artifactContentQuery.isLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground dark:text-[#8aa0c4]">
<Loader2 className="size-4 animate-spin" />
正在加载 Markdown 文件内容…
</div>
) : (
<ArtifactFilePreview
content={displayedContent}
language="markdown"
threadId={artifactThreadId ?? ""}
/>
)}
</div>
{artifactLoadFailed && (
<p className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
Markdown 文件内容加载失败,当前展示的是已保存正文。
</p>
)}
</div>
</div>
)}
<DialogFooter className="shrink-0 border-t px-5 py-3 dark:border-[#2b8ffe]/25">
{footerStart}
<TButton variant="outline" onClick={() => onOpenChange(false)}>
关闭
</TButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,130 @@
.analysis-result-card-list {
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-card {
position: relative;
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
min-height: 102px;
box-sizing: border-box;
padding: 40px 12px 12px;
border: 1px solid rgba(85, 166, 254, 0.1);
border-radius: 4px;
background: rgba(0, 98, 217, 0.1);
color: #ffffff;
text-align: left;
cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
}
.position-roundtable-analysis-result-card:hover,
.position-roundtable-analysis-result-card:focus-visible {
border-color: #0062d9;
background: rgba(0, 98, 217, 0.3);
box-shadow: 0 4px 14px rgba(0, 44, 110, 0.18);
outline: none;
}
.position-roundtable-analysis-result-card-body {
position: relative;
min-width: 0;
max-width: 100%;
}
.position-roundtable-analysis-result-tag {
position: absolute;
top: -40px;
left: -12px;
display: inline-flex;
width: fit-content;
max-width: calc(100% + 12px);
height: 26px;
align-items: center;
box-sizing: border-box;
padding: 0 22px 0 12px;
overflow: hidden;
border-radius: 4px 0 0 0;
background: rgba(212, 120, 0, 0.5);
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
line-height: normal;
white-space: nowrap;
clip-path: polygon(0 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
}
.position-roundtable-analysis-result-tag--1 { background: rgba(212, 120, 0, 0.5); }
.position-roundtable-analysis-result-tag--2 { background: rgba(207, 19, 34, 0.5); }
.position-roundtable-analysis-result-tag--3 { background: rgba(29, 171, 123, 0.5); }
.position-roundtable-analysis-result-tag--4 { background: rgba(132, 85, 230, 0.5); }
.position-roundtable-analysis-result-tag--5 { background: rgba(201, 181, 0, 0.5); }
.position-roundtable-analysis-result-tag--6 { background: rgba(69, 195, 243, 0.5); }
.position-roundtable-analysis-result-tag span {
position: relative;
z-index: 1;
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
}
.position-roundtable-analysis-result-product {
margin: 0;
overflow: hidden;
color: #ffffff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
margin-top: 6px;
color: #80b7ff;
font-family: "Noto Sans S Chinese", "Noto Sans SC", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 24px;
}
.position-roundtable-analysis-result-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.position-roundtable-analysis-result-meta img {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.position-roundtable-analysis-result-meta i {
width: 1px;
height: 14px;
flex: 0 0 auto;
background: rgba(128, 183, 255, 0.45);
}
.position-roundtable-analysis-results-empty {
color: #80b7ff;
}
@@ -0,0 +1,3 @@
export { AnalysisResultCardList } from "./AnalysisResultCardList";
export { AnalysisResultDetailDialog } from "./AnalysisResultDetailDialog";
export type { AnalysisResultCardItem } from "./types";
@@ -0,0 +1,17 @@
export interface AnalysisResultCardItem {
id: string;
agentName: string;
productName: string;
positionName: string;
updatedAt?: string;
/** Shown when `updatedAt` is missing. */
fallbackTime?: string;
/** Clickable preview is available. */
delivered: boolean;
artifactPath?: string;
artifactMimeType?: string | null;
artifactThreadId?: string | null;
artifactContent?: string | null;
summary?: string;
unavailableMessage?: string;
}
@@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
bootstrapResumePath,
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
sortThreadsPinnedFirst,
@@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
// 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示
// (带机器人图标、点击回创建页);置顶优先。
// 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致;
// 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -2,6 +2,7 @@
import {
BotIcon,
BriefcaseBusinessIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
@@ -150,6 +151,7 @@ export function AgentDetailPanel({
skillsIcon,
skillsTitle,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
embedded,
hideStatusBadges = false,
@@ -170,6 +172,11 @@ export function AgentDetailPanel({
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
*/
jobResponsibilities?: string | null;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
@@ -720,6 +727,23 @@ export function AgentDetailPanel({
</section>
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
{jobResponsibilities !== undefined && (
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{jobResponsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(jobResponsibilities)}
</p>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
)}
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
@@ -346,15 +346,22 @@ export function AgentGallery() {
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSubmitPassword();
}}
>
<label className="sr-only" htmlFor="private-square-access-code">
访问密码
</label>
<Input
id="private-square-access-code"
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
@@ -365,8 +372,8 @@ export function AgentGallery() {
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
<Button type="submit">进入私密广场</Button>
</form>
</div>
);
}
@@ -5,7 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useThreadsPage } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
pageSize: RECENT_LIMIT,
metadata: { agent_id: agentId },
});
// 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。
// 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。
const threads = (data?.threads ?? []).filter(
(th) =>
th.metadata?.thread_type !== "bootstrap" &&
th.metadata?.thread_type !== "scheduler" &&
th.metadata?.thread_type !== "roundtable",
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
);
return (
@@ -14,11 +14,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
)}
title={button.label}
onClick={() => {
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
url: appendShortcutPathParams(button.url, routeParams),
routeParams: routeParams.filter(isQueryShortcutParam),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,6 +29,7 @@ export function ArtifactFileList({
threadId,
onOpenArtifact,
onDownloadArtifact,
fileDescriptions,
}: {
className?: string;
files: string[];
@@ -36,6 +37,8 @@ export function ArtifactFileList({
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional per-file secondary text, such as the report structure used. */
fileDescriptions?: Record<string, ReactNode>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -102,10 +105,10 @@ export function ArtifactFileList({
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
</div>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
@@ -56,12 +56,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifactsState] = useState<string[]>([]);
const setArtifacts = useCallback((next: string[]) => {
const normalized = Array.isArray(next) ? next : [];
setArtifactsState((current) => {
const unchanged =
current.length === normalized.length &&
current.every((artifact, index) => artifact === normalized[index]);
return unchanged ? current : normalized;
});
setArtifactsState((previous) =>
previous.length === normalized.length &&
previous.every((artifact, index) => artifact === normalized[index])
? previous
: normalized,
);
}, []);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
@@ -71,18 +71,24 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const [autoOpen, setAutoOpen] = useState(true);
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
const autoOpenRef = useRef(autoOpen);
const sidebar = useSidebarSafe();
const select = useCallback(
const select = useCallback(
(artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact);
setSelectedArtifactKey(artifactPreviewKey(artifact));
const previewKey = artifactPreviewKey(artifact);
selectedArtifactKeyRef.current = previewKey;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
if (!autoSelect) {
setAutoSelect(false);
}
if (!autoSelect) {
setAutoSelect((previous) => (previous ? false : previous));
}
},
[sidebar],
);
@@ -92,11 +98,13 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
if (suppressedAutoOpenKeyRef.current === previewKey) {
return false;
}
setSelectedArtifact(artifact);
setSelectedArtifactKey(previewKey);
setAutoSelect(true);
setAutoOpen(true);
setOpenState(true);
selectedArtifactKeyRef.current = previewKey;
autoOpenRef.current = true;
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
setSelectedArtifactKey((previous) => (previous === previewKey ? previous : previewKey));
setAutoSelect((previous) => (previous ? previous : true));
setAutoOpen((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? previous : true));
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
sidebar?.setOpen(false);
}
@@ -106,34 +114,33 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
);
const deselect = useCallback(() => {
setSelectedArtifact(null);
setSelectedArtifactKey(null);
setAutoSelect(true);
setOpenState(false);
selectedArtifactKeyRef.current = null;
setSelectedArtifact((previous) => (previous === null ? previous : null));
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
setAutoSelect((previous) => (previous ? previous : true));
setOpenState((previous) => (previous ? false : previous));
suppressedAutoOpenKeyRef.current = null;
}, []);
const setOpen = useCallback(
(isOpen: boolean) => {
if (!isOpen && autoOpen) {
setAutoOpen(false);
setAutoSelect(false);
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
}
setOpenState(isOpen);
},
[autoOpen, selectedArtifactKey],
);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
const setOpen = useCallback((isOpen: boolean) => {
if (!isOpen && autoOpenRef.current) {
autoOpenRef.current = false;
setAutoOpen((previous) => (previous ? false : previous));
setAutoSelect((previous) => (previous ? false : previous));
}
if (!isOpen) {
suppressedAutoOpenKeyRef.current = selectedArtifactKeyRef.current;
}
setOpenState((previous) => (previous === isOpen ? previous : isOpen));
}, []);
const value: ArtifactsContextType = {
artifacts,
setArtifacts,
open,
autoOpen,
autoSelect,
setOpen,
selectedArtifact,
@@ -141,7 +148,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
select,
openStreamedArtifact,
deselect,
};
};
return (
<ArtifactsContext.Provider value={value}>
@@ -1,7 +1,7 @@
"use client";
import { SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -20,8 +20,24 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
export interface DocumentRewriteDialogInput {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
@@ -51,64 +67,130 @@ export function DocumentRewriteDialog({
onOpenChange,
fileName,
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
initialReportOutline,
operationLabel,
operationDescription,
submitLabel,
operation,
onStart,
onRewriteOriginal,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
fileName: string;
charCount: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
}) => void;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
/** A saved report structure selected before the dialog was opened. */
initialReportOutline?: string;
/** Optional copy for flows that create a new document instead of replacing one. */
operationLabel?: string;
operationDescription?: string;
submitLabel?: string;
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
operation?: "rewrite" | "generate";
onStart: (input: DocumentRewriteDialogInput) => void;
/** Optional secondary action for generation flows that rewrites the source report in place. */
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const isNewReportGeneration = operation === "generate";
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
const reportStructures = reportStructuresQuery.data ?? [];
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
const initialReportStructureId = useMemo(() => {
const outline = initialReportOutline?.trim();
if (!outline) return null;
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
}, [initialReportOutline, reportStructures]);
useEffect(() => {
if (!open) return;
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
setStyle("formal_analysis");
setModelName(null);
}, [open]);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
// The structures query may resolve after the dialog opens. Only seed the
// value while the user still has the default selection, so a manual choice
// is never overwritten by a query refresh.
useEffect(() => {
if (!open || !initialReportStructureId) return;
setReportStructureId((current) => (
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
if (!canStart) return;
handler({
instruction: instruction.trim(),
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
{operationLabel ?? "AI 全文改写"}
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
<div className="text-muted-foreground mt-1">
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
</div>
</div>
<label className="grid gap-2 text-sm font-medium">
改写要求
{isNewReportGeneration ? "生成要求" : "改写要求"}
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
placeholder={isNewReportGeneration
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
改写风格
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
@@ -124,6 +206,58 @@ export function DocumentRewriteDialog({
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
报告结构
<Select value={reportStructureId} onValueChange={setReportStructureId}>
<SelectTrigger aria-label="报告结构">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
{reportStructuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>
正在加载结构…
</SelectItem>
)}
{reportStructuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>
报告结构库加载失败
</SelectItem>
)}
{reportStructures.map((structure) => (
<SelectItem key={structure.id} value={structure.id}>
{structure.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<Select
value={structureMode}
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -132,23 +266,76 @@ export function DocumentRewriteDialog({
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
<span className="text-muted-foreground text-xs">
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
</span>
</label>
</div>
{imageGenerationAvailable && (
<div className="rounded-lg border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-4">
<label className="flex min-w-0 cursor-pointer items-start gap-3" htmlFor="document-rewrite-images">
<span className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
/>
</div>
{generateImages && (
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">每次会额外消耗图片模型额度</span>
<label className="flex items-center gap-2 text-sm">
配图数量
<Select value={imageCount} onValueChange={setImageCount}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: Math.min(Math.max(maxGeneratedImages, 1), 4) }, (_, index) => index + 1).map((count) => (
<SelectItem key={count} value={String(count)}>{count} 张</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
{isNewReportGeneration && onRewriteOriginal && (
<Button
variant="outline"
disabled={!canStart}
onClick={() => startWith(onRewriteOriginal)}
>
<FilePenLineIcon />
改写原文
</Button>
)}
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({ instruction: instruction.trim(), style, modelName });
}}
onClick={() => startWith(onStart)}
>
<SparklesIcon />
开始改写
{submitLabel ?? "开始改写"}
</Button>
</DialogFooter>
</DialogContent>
@@ -916,11 +916,10 @@ function buildTurnReferenceBatches(
continue;
}
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
if (nextHumanIndex === -1 && isLoading) {
continue;
}
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
const assistant =
findLastAssistantAnswer(messages, index + 1, endIndex) ??
findFrontendRagAssistant(messages, index + 1, endIndex);
if (!assistant?.id) {
continue;
}
@@ -930,6 +929,14 @@ function buildTurnReferenceBatches(
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
includeBeforeMessage: false,
}).filter((source) => source.mode !== "none");
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
if (sources.length > 0) {
batches.push({
id: assistant.id,
@@ -968,6 +975,23 @@ function findLastAssistantAnswer(
return null;
}
function findFrontendRagAssistant(
messages: Message[],
start: number,
end: number,
): Message | null {
for (let index = end - 1; index >= start; index -= 1) {
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
) {
return message;
}
}
return null;
}
function hasAssistantAnswerContent(message: Message): boolean {
const content = message.content;
if (typeof content === "string") {
@@ -76,6 +76,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -226,14 +227,8 @@ export function GeneralQaSidebar() {
const visibleThreads = useMemo(
() =>
// 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
),
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
[threads],
);
@@ -2,34 +2,61 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { apiFetch } from "@/core/api/fetch-client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
loadArtifactRewriteFallback,
sha256DocumentContent,
} from "@/core/document-rewrite/local-fallback";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验文档" },
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
const GENERATE_STEPS = [
{ key: "snapshot", label: "固定来源报告快照" },
{ key: "requirements", label: "确认用户生成需求" },
{ key: "writing", label: "流式生成 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验新报告" },
{ key: "comparison", label: "生成报告说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
@@ -38,15 +65,17 @@ function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
return 1;
case "writing":
return 2;
case "validation":
case "illustrating":
return 3;
case "validation":
return 4;
case "committing":
case "completed":
return 5;
return 6;
case "conflict":
case "error":
case "cancelled":
return 4;
return 5;
default:
return -1;
}
@@ -56,23 +85,218 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
return ["completed", "conflict", "error", "cancelled"].includes(stage);
}
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const contentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const previousActiveRef = useRef(active);
const lastAutoScrollTopRef = useRef<number | null>(null);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
lastAutoScrollTopRef.current = null;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = contentRef.current;
if (!content) return;
content.scrollTop = content.scrollHeight;
lastAutoScrollTopRef.current = content.scrollTop;
}, []);
useLayoutEffect(() => {
if (!active || !open || !text) return;
scrollToLatestThinking();
}, [active, open, scrollToLatestThinking, text]);
useEffect(() => {
if (!active || !open || !text) return;
const content = contentRef.current;
if (!content) return;
let frame: number | null = null;
const observer = new MutationObserver(() => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
scrollToLatestThinking();
});
});
observer.observe(content, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
if (frame !== null) cancelAnimationFrame(frame);
};
}, [active, open, scrollToLatestThinking, text]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current) return;
const lastAutoScrollTop = lastAutoScrollTopRef.current;
if (
lastAutoScrollTop === null ||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
) {
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
}
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
<CollapsibleTrigger asChild>
<Button
aria-label={open ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={contentRef}
>
{text}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function DocumentRewriteTimeline({
item,
}: {
item: DocumentRewritePresentation;
}) {
const isNewReportGeneration = item.operation === "generate";
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
const thinkingStep = ["snapshot", "requirements"].includes(item.stage)
? "requirements"
: "writing";
const presentation = useDocumentRewritePresentation();
const [undoing, setUndoing] = useState(false);
const [diffOpen, setDiffOpen] = useState(false);
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const canUseLocalFallback =
!reapplying &&
!activeVersionId &&
item.source.kind === "artifact" &&
typeof item.source.path === "string" &&
item.localFallbackAvailable &&
typeof item.committedHash === "string";
const handleUndo = async () => {
if (!activeVersionId || undoing) return;
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
setUndoing(true);
try {
const fallback = loadArtifactRewriteFallback({
threadId: item.source.id,
path: item.source.path,
committedHash: item.committedHash,
});
if (!fallback) {
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
return;
}
const currentResponse = await apiFetch(
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
);
if (!currentResponse.ok) {
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
}
const currentHash = await sha256DocumentContent(await currentResponse.text());
if (!currentHash || currentHash !== fallback.committedHash) {
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
return;
}
const guard = new CustomEvent<{
source: DocumentRewritePresentation["source"];
rewrittenContent: string | null;
unchanged: boolean;
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
detail: {
source: item.source,
rewrittenContent: item.rewrittenContent,
unchanged: false,
},
});
window.dispatchEvent(guard);
if (!guard.detail.unchanged) {
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
return;
}
await updateArtifactContent({
filepath: item.source.path,
threadId: item.source.id,
content: fallback.originalContent,
});
clearArtifactRewriteFallback(item.source.id, item.source.path);
presentation?.update(item.id, {
localFallbackAvailable: false,
revertedAt: new Date().toISOString(),
});
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content: fallback.originalContent },
}),
);
} catch (error) {
console.error("本地 AI 全文改写撤销失败:", error);
} finally {
setUndoing(false);
}
return;
}
if (!activeVersionId) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
@@ -109,7 +333,15 @@ export function DocumentRewriteTimeline({
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
toast.success(
isNewReportGeneration
? reapplying
? "已重新应用本次生成的新报告版本。"
: "已恢复本次生成前的新报告快照。"
: reapplying
? "已重新应用本次 AI 全文改写版本。"
: "已恢复到本次 AI 全文改写前的版本。",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
@@ -125,7 +357,9 @@ export function DocumentRewriteTimeline({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
<h3 className="font-semibold">
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
</h3>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
@@ -136,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -146,9 +380,15 @@ export function DocumentRewriteTimeline({
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
{steps.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const done = completed || index < current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
step.key === "illustrations" &&
item.generateImages &&
completed &&
item.generatedImageCount === 0;
const done = !illustrationSkipped && !illustrationFailed && (completed || index < current);
return (
<div className="flex items-start gap-3" key={step.key}>
<div
@@ -173,7 +413,7 @@ export function DocumentRewriteTimeline({
<div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
</p>
)}
{step.key === "requirements" && (
@@ -187,18 +427,52 @@ export function DocumentRewriteTimeline({
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
{isNewReportGeneration
? "正在确认新报告的结构、资料范围与生成要求…"
: "正在识别改写目标、保留约束与执行方式…"}
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p>
) : null
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
<p className="text-muted-foreground mt-0.5 text-xs">
本次未启用配图,保留原文已有图片与链接。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{item.illustrationTarget
? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。`
: `正在准备 ${item.maxGeneratedImages} 张文档配图…`}
</p>
) : item.generatedImageCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
已生成并保存 {item.generatedImageCount} 张本地配图。
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
未成功生成配图,文字改写结果仍已保留。
</p>
)
)}
{step.key === thinkingStep && (
<RewriteThinkingTrace active={active} text={item.thinkingText} />
)}
{step.key === "validation" && item.warnings.length > 0 && (
<p className="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
@@ -213,9 +487,10 @@ export function DocumentRewriteTimeline({
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
@@ -228,6 +503,11 @@ export function DocumentRewriteTimeline({
本次优化:{item.summary.requestedOptimizations.join("、")}。
</p>
) : null}
{item.summary.requestedImageCount ? (
<p className="text-muted-foreground">
文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。
</p>
) : null}
{item.summary.headingChanges && (
item.summary.headingChanges.reordered ||
item.summary.headingChanges.added.length > 0 ||
@@ -256,7 +536,25 @@ export function DocumentRewriteTimeline({
查看前后对比
</Button>
)}
{completed && activeVersionId && (
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
<Button
className="mt-3 mr-2"
onClick={() => {
window.dispatchEvent(
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
detail: { sessionId: item.source.id },
}),
);
}}
size="sm"
type="button"
variant="outline"
>
<FilePenLineIcon className="mr-1.5 size-3.5" />
改写新报告
</Button>
)}
{completed && (activeVersionId || canUseLocalFallback) && (
<Button
className="mt-3"
disabled={undoing}
@@ -266,12 +564,19 @@ export function DocumentRewriteTimeline({
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
{isNewReportGeneration
? reapplying
? "重新应用本次生成"
: "撤销本次生成"
: reapplying
? "重新应用此次改写"
: "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
</p>
)}
</div>
@@ -1,21 +1,22 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronUp,
ChevronsDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
LightbulbIcon,
ListTodoIcon,
Loader2Icon,
MessageCircleQuestionMarkIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import {
ChainOfThought,
@@ -31,20 +32,20 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
import { useI18n } from "@/core/i18n/hooks";
import {
normalizeSearchResults,
resolveSearchQuery,
@@ -58,33 +59,38 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
applyInlineCitations,
buildSkillDisplayMap,
findDisplayConfigForTool,
isDisplaySamplePayload,
normalizeDefaultDisplaySources,
shouldUseDefaultCitationMode,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({
className,
messages,
isLoading = false,
displaySources = [],
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
@@ -121,11 +127,29 @@ export function MessageGroup({
}
}, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
open={true}
>
<>
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
data-reference-batch-id={reasoningId}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (
<Button
key="above"
@@ -160,10 +184,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={step.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -220,10 +246,12 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MarkdownContent
<StepReasoningMarkdown
content={lastReasoningStep.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/>
}
></ChainOfThoughtStep>
@@ -231,11 +259,64 @@ export function MessageGroup({
)}
</>
)}
</ChainOfThought>
</ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
function ToolCall({
function StepReasoningMarkdown({
content,
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
[content, displaySources],
);
const components = useMemo(() => {
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
return (
<MarkdownContent
content={markdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
);
}
function ToolCall({
id,
messageId,
name,
@@ -260,14 +341,14 @@ function ToolCall({
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
@@ -278,9 +359,9 @@ function ToolCall({
: "Wiki 文档检索"
: null;
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
return (
<ChainOfThoughtStep
key={id}
label={
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
? "检索参考来源"
@@ -291,17 +372,17 @@ function ToolCall({
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
}
// Deep Research uses a virtual tool call for each durable writing phase.
// Rendering it here keeps its lifecycle in the very same ChainOfThought
// component as ordinary Q&A retrieval and sandbox tool calls.
if (name === "deep_research_progress") {
return <DeepResearchProgressStep id={id} args={args} />;
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
@@ -314,19 +395,19 @@ function ToolCall({
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (query) {
label =
name === "web_search"
? `知识库检索 "${query}"`
: t.toolCalls.searchFor(query);
} else if (name === "web_search") {
label = "知识库检索";
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{searchItems.length > 0 && (
<WebSearchResultsList results={searchItems} />
)}
@@ -443,18 +524,18 @@ function ToolCall({
if (!description) {
description = t.toolCalls.writeFile;
}
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
return (
<ArtifactWriteToolCall
id={id}
messageId={messageId}
name={name}
args={args}
result={result}
description={description}
isLast={isLast}
isLoading={isLoading}
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
@@ -515,129 +596,235 @@ function ToolCall({
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
}
}
function DeepResearchProgressStep({
id,
args,
}: {
id?: string;
args: Record<string, unknown>;
}) {
const label = typeof args.label === "string" && args.label.trim()
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: sourcePath;
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
previousActiveRef.current = active;
}, [active]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
return (
<ChainOfThoughtStep
key={id}
status={active ? "active" : "complete"}
icon={active
? <Loader2Icon className="text-primary size-4 animate-spin" />
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
label={
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span>{label}</span>
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
{active ? "进行中" : "已完成"}
</span>
</span>
}
>
{detail && (
<div className="text-muted-foreground text-xs leading-5">
{detail}
</div>
)}
{thinking && (
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
<CollapsibleTrigger asChild>
<Button
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
className="h-7 gap-1.5 px-2 text-xs"
size="sm"
type="button"
variant="ghost"
>
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
<span>模型思考</span>
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</ChainOfThoughtStep>
);
}
function ArtifactWriteToolCall({
id,
messageId,
name,
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
messageId?: string;
name: "write_file" | "str_replace";
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
description: string;
isLast: boolean;
isLoading: boolean;
}) {
const artifacts = useArtifactsOptional();
const openStreamedArtifact = artifacts?.openStreamedArtifact;
const rawPath = typeof args.path === "string" ? args.path : "";
const content = typeof args.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: name === "write_file" && content.trim()
? provisionalWriteFilePath(content)
: null;
const previewKey = messageId && id ? `${messageId}:${id}` : null;
// Never select a bare provisional name like `未命名文件.md` — the artifacts
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
// tool call has ids so we can open a write-file: memory preview instead.
const artifactPath =
sourcePath && name === "write_file" && messageId && id
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
: null;
useEffect(() => {
if (
!openStreamedArtifact ||
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
]);
return (
<ChainOfThoughtStep
key={id}
className={cn(artifactPath && "cursor-pointer")}
label={description}
icon={NotebookPenIcon}
onClick={() => {
if (!artifactPath || !artifacts) return;
artifacts.select(artifactPath);
artifacts.setOpen(true);
}}
>
{(normalizedPath || sourcePath) && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{normalizedPath || sourcePath}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
}
interface GenericCoTStep<T extends string = string> {
id?: string;
messageId?: string;
type: T;
}
@@ -740,7 +927,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
open && "rotate-180",
)}
/>
<span>{results.length} 条真实返回结果</span>
<span>{results.length} 条真实返回结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
@@ -8,6 +8,7 @@ import {
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
@@ -18,6 +19,7 @@ import {
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
@@ -693,6 +695,10 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -780,7 +786,7 @@ function MessageContent_({
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
@@ -789,7 +795,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
)}
@@ -838,7 +844,7 @@ function FrontendRagSourcesPanel({
const [open, setOpen] = useState(false);
if (sources.length === 0) return null;
return (
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -891,7 +897,7 @@ function FrontendRagSourcesPanel({
);
}
function SingleRoundRagCitationDialog({
export function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
@@ -947,11 +953,14 @@ function MultiRoundRagContent({
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
@@ -965,10 +974,10 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
@@ -980,14 +989,26 @@ function MultiRoundRagContent({
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
<div
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
@@ -1017,85 +1038,244 @@ function MultiRoundRagContent({
function MultiRoundRagRoundCard({
index,
round,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
const thoughtContent = useMemo(
() =>
applyInlineCitations(round.thought, displaySources, {
appendSummary: false,
}),
[displaySources, round.thought],
);
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
<MarkdownContent
content={thoughtContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</details>
))}
</MultiRoundRagDetailSection>
)}
</div>
</section>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
@@ -11,8 +11,11 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import {
DOCUMENT_REWRITE_STARTED_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -46,8 +49,8 @@ import { getQuestionAnchorId } from "../question-navigator";
import { StreamingIndicator } from "../streaming-indicator";
import { ReferenceBatchesContext, ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MarkdownContent } from "./markdown-content";
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import {
@@ -89,6 +92,22 @@ function ScrollToBottomOnUserMessage({
}
}, [messages, scrollToBottom]);
useEffect(() => {
const handleDocumentRewriteStarted = (event: Event) => {
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
if (detail?.threadId !== threadId) return;
// The presentation card is inserted by a context update in the same
// interaction. Two frames let React commit it before the smooth scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void scrollToBottom("smooth");
});
});
};
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
@@ -112,10 +131,13 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
}: {
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
@@ -137,24 +159,28 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
*/
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
researchApprovalSlot?: (message: Message) => React.ReactNode;
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const updateSubtask = useUpdateSubtask();
const documentRewritePresentation = useDocumentRewritePresentation();
const { t } = useI18n();
const threadContext = useContext(ThreadContext);
// Read-only contexts (public share, mock previews) must not offer deletion.
@@ -194,7 +220,7 @@ export function MessageList({
: ({ ...message, content: override } as Message);
});
}, [messageContentOverrides, thread.messages]);
const messages = useMemo(
const messages = useMemo(
() => {
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
return hiddenMessageIds.size === 0 && externalHidden.size === 0
@@ -205,9 +231,9 @@ export function MessageList({
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
);
},
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
);
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
const askTimes = useChatAskTimes();
@@ -322,6 +348,32 @@ export function MessageList({
return map;
}, [thread.messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
const frontendRagSources = msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
},
[
frontendRagSourcesByAiId,
messages,
referenceBatches,
referencesEnabled,
skillDisplayMap,
],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -438,44 +490,35 @@ export function MessageList({
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
"mx-auto w-full max-w-(--container-width-lg)",
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
const messageIsLoading =
thread.isLoading ||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
const isAssistantMessage =
referencesEnabled &&
group.type === "assistant" &&
msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage
? batchSources.length > 0
? batchSources
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
? resolveDisplaySources(msg)
: [];
return (
<MessageListItem
@@ -485,9 +528,9 @@ export function MessageList({
msg.type === "human"
? getQuestionAnchorId(msg, messages.indexOf(msg))
: undefined
}
message={msg}
isLoading={messageIsLoading}
}
message={msg}
isLoading={messageIsLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
@@ -582,9 +625,26 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
const message = group.messages[0];
return researchApprovalSlot && message ? (
<div key={group.id} className="w-full pb-2">
@@ -599,8 +659,15 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
@@ -610,11 +677,11 @@ export function MessageList({
/>
)}
<ArtifactFileList
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
files={files}
threadId={threadId}
onOpenArtifact={onOpenArtifact}
onDownloadArtifact={onDownloadArtifact}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
@@ -682,6 +749,7 @@ export function MessageList({
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
/>,
);
}
@@ -725,11 +793,15 @@ export function MessageList({
message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)),
);
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return (
<div key={"group-" + group.id} className="w-full">
<MessageGroup
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -738,11 +810,21 @@ export function MessageList({
/>
</div>
);
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{thread.isLoading && <StreamingIndicator className="my-4" />}
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
return (
<>
{grouped}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{afterDocumentRewriteItems}
</>
);
})()}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
@@ -799,6 +881,15 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -1169,6 +1260,147 @@ function SetupWritingApprovalCard({
);
}
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -66,6 +66,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isBootstrapThread,
isHiddenFromRecentChats,
isThreadFavorite,
isThreadPinned,
listPathOfThread,
@@ -193,13 +194,9 @@ export function RecentChatList({
);
// 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页);
// 仅排除调度 / 会商类系统会话。
// 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable",
),
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
);
if (visibleThreads.length === 0) {
@@ -54,6 +54,7 @@ import type {
CompactShortcutRouteParam,
} from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { createClientId } from "@/lib/client-id";
import {
APP_THEME_COLORS,
LOGIN_SOURCE_OPTIONS,
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
function makeShortcutId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return createClientId("shortcut");
}
function newShortcutParam(): CompactShortcutRouteParam {
@@ -119,7 +117,7 @@ function normalizeShortcutDraft(
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
key: param.paramType === "path" ? "" : param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
@@ -134,8 +132,18 @@ function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.filter((param) => param.paramType === "path" || param.key.trim())
.map((param) => {
if (param.paramType === "path") {
const source = param.source || "access_token";
return {
key: "",
paramType: "path" as const,
source,
customValue:
source === "other" ? (param.customValue ?? "").trim() : undefined,
};
}
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
@@ -356,6 +364,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
return;
}
for (const param of item.route_params) {
if (param.paramType === "path") {
if (param.source === "other" && !param.customValue?.trim()) {
toast.error("路径参数自定义值不可为空");
return;
}
continue;
}
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
@@ -550,7 +565,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
@@ -917,7 +932,7 @@ function CompactShortcutSettings({
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
@@ -1014,7 +1029,7 @@ function CompactShortcutSettings({
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
@@ -1030,13 +1045,13 @@ function CompactShortcutSettings({
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
@@ -1046,26 +1061,36 @@ function CompactShortcutSettings({
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<div
className={cn(
"grid gap-2",
param.paramType === "path"
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
)}
>
{param.paramType !== "path" && (
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
)}
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
paramType: value as "login" | "theme" | "path",
key: value === "path" ? "" : param.key,
source:
value === "login"
? param.source || "access_token"
: undefined,
value === "theme"
? undefined
: param.source || "access_token",
})
}
>
@@ -1075,9 +1100,10 @@ function CompactShortcutSettings({
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
<SelectItem value="path">路径参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
{(param.paramType === "login" || param.paramType === "path") ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
@@ -1103,6 +1129,11 @@ function CompactShortcutSettings({
</Select>
{param.source === "other" && (
<Input
// This config value becomes a query parameter;
// it is not a login form field. A password input
// makes Chrome invoke password-manager heuristics
// whenever the settings dialog opens.
type="text"
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
@@ -1111,6 +1142,8 @@ function CompactShortcutSettings({
}
placeholder="自定义值"
className="w-32 text-xs"
autoComplete="off"
aria-label="自定义参数值"
/>
)}
</div>
@@ -1133,6 +1166,12 @@ function CompactShortcutSettings({
</Button>
</div>
{param.paramType === "path" && (
<p className="text-muted-foreground text-xs">
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
</p>
)}
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
@@ -1166,9 +1205,9 @@ function CompactShortcutSettings({
)}
</div>
);
})}
</div>
)}
})}
</div>
)}
</div>
</div>
))}
@@ -43,6 +43,7 @@ import {
customThemeTextContrast,
type CustomThemeAdvancedKey,
} from "@/core/theme/custom-theme";
import { createClientId } from "@/lib/client-id";
import { cn } from "@/lib/utils";
const MAX_CUSTOM_THEMES = 12;
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
];
function makeThemeId(): `custom-theme-${string}` {
const suffix =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
return `custom-theme-${suffix}`;
}
@@ -14,7 +14,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/workspace/enterprise-research",
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
// },
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
@@ -7,9 +7,11 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
CpuIcon,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon,
MessageSquareTextIcon,
ScrollTextIcon,
@@ -227,6 +229,24 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<CpuIcon />
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>