feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-19 20:37:34 +08:00
1 parent be4851555d
commit 0ce060693b
42 files changed
+5320 -3247

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>
),
);
@@ -2,6 +2,7 @@
import {
BotIcon,
BriefcaseBusinessIcon,
CheckIcon,
DatabaseIcon,
GlobeIcon,
@@ -83,6 +84,7 @@ interface AgentDraft {
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
knowledgeBaseIds: string[];
jobResponsibilities: string;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -97,6 +99,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
jobResponsibilities: agent.job_responsibilities ?? "",
};
}
@@ -338,6 +341,8 @@ export function AgentDetailPanel({
request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
if (draft.jobResponsibilities.trim() !== (agent.job_responsibilities ?? ""))
request.job_responsibilities = draft.jobResponsibilities.trim();
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
@@ -720,6 +725,39 @@ export function AgentDetailPanel({
</section>
{/* ── 岗位职责 ───────────────────────────────────────────────────── */}
<Section
icon={<BriefcaseBusinessIcon className="size-3.5" />}
title="岗位职责"
>
{editing && draft ? (
<Textarea
value={draft.jobResponsibilities}
onChange={(e) =>
setDraft({ ...draft, jobResponsibilities: e.currentTarget.value })
}
placeholder="描述该智能体对应岗位的职责…"
className="min-h-20 text-sm"
/>
) : agent.job_responsibilities?.trim() ? (
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
{desensitize(agent.job_responsibilities)}
</p>
) : canEdit ? (
<button
type="button"
onClick={beginEdit}
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
>
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
添加岗位职责
</button>
) : (
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
)}
</Section>
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
@@ -6,10 +6,10 @@ import {
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -17,10 +17,10 @@ import {
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
type ReactNode,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from '@/components/ai-elements/loader';
@@ -695,6 +695,17 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
const reasoningToDisplay = useMemo(() => {
if (!reasoningContent) return '';
const withCitations = applyInlineCitations(reasoningContent, displaySources, {
appendSummary: false,
});
return stripIncompleteCitationMarker(withCitations);
}, [reasoningContent, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
);
const parsedHumanContent = useMemo(
() =>
isHuman
@@ -724,23 +735,6 @@ function MessageContent_({
);
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
</AIElementMessageContent>
);
}
if (isHuman) {
// Only the user's own question carries a delete affordance, and only once
// it exists server-side (real, non-optimistic id). Deleting it cascades to
@@ -776,25 +770,43 @@ function MessageContent_({
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{reasoningToDisplay && !multiRoundTrace && (
<Reasoning isStreaming={isLoading && !contentToDisplay}>
<ReasoningTrigger />
<ReasoningContent>
{isSingleRoundRag || displaySources.length > 0 ? (
<MarkdownContent
content={reasoningToDisplay}
isLoading={isLoading}
rehypePlugins={markdownRehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
) : (
reasoningToDisplay
)}
</ReasoningContent>
</Reasoning>
)}
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
) : contentToDisplay ? (
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
)}
) : null}
</div>
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
<FrontendRagSourcesPanel
@@ -840,7 +852,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 max-w-5xl">
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
<CollapsibleTrigger asChild>
<button
type="button"
@@ -946,17 +958,17 @@ function MultiRoundRagContent({
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// 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]);
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// 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="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<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"
@@ -970,42 +982,43 @@ function MultiRoundRagContent({
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
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}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
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>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
@@ -1030,240 +1043,249 @@ function MultiRoundRagContent({
);
}
function MultiRoundRagRoundCard({
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]);
return (
<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" />}
>
<MarkdownContent
content={round.thought}
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>
)}
</MultiRoundRagDetailSection>
)}
</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>
);
}
}) {
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 (
<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" />}
>
<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>
)}
</MultiRoundRagDetailSection>
)}
</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>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
@@ -10,6 +10,7 @@ import {
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
MessageCircleQuestionIcon,
ScrollTextIcon,
Settings2Icon,
@@ -226,6 +227,16 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/menu-management")}
>
<ListTreeIcon />
菜单管理
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")}
>
+14 -8
View File
@@ -61,9 +61,11 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
/** 岗位职责。会商智能体配置面板展示在常用问题上方。空/缺省 = 未填写。 */
job_responsibilities?: string | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -98,8 +100,10 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
seat_skill_directive?: boolean | null;
/** 岗位职责;缺省/空 = 未填写。 */
job_responsibilities?: string | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -116,6 +120,8 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
seat_skill_directive?: boolean | null;
/** 岗位职责;传空字符串表示清除。 */
job_responsibilities?: string | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
@@ -82,3 +82,14 @@ test("accepts spaced and full-width square brackets", () => {
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("can skip the trailing source summary", () => {
const withSummary = applyInlineCitations("没有编号。", makeSources(2));
assert.equal(withSummary.includes("参考来源"), true);
const withoutSummary = applyInlineCitations("没有编号。", makeSources(2), {
appendSummary: false,
});
assert.equal(withoutSummary, "没有编号。");
assert.equal(withoutSummary.includes("#reference-"), false);
});
@@ -509,6 +509,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
export function applyInlineCitations(
content: string,
sources: RuntimeDisplaySource[],
options?: { appendSummary?: boolean },
): string {
if (!content || sources.length === 0) {
return content;
@@ -563,6 +564,9 @@ export function applyInlineCitations(
if (replaced || hasCitationMarkdown(next)) {
return next;
}
if (options?.appendSummary === false) {
return next;
}
return appendCitationSummary(next, referenceSources);
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useSearchParams } from "react-router-dom";
import {
ChevronDown,
ChevronLeft,
@@ -19,15 +20,23 @@ import backArrowIcon from "@/assets/eightbf-roundtable/back-arrow.svg";
import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg";
import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg";
import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg";
import { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
import {
parentReturnRoute,
requestEightBfParentReturn,
} from "@/eightbf-roundtable/lib/parent-return";
import { useEightBfAnalysisResults } from "@/eightbf-roundtable/lib/collect-md-artifacts";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import { cn } from "@/lib/utils";
type IntentRecord = Record<string, unknown>;
@@ -47,13 +56,16 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
}
export function EightBfRoundtableBackButton() {
const [searchParams] = useSearchParams();
const handleBack = () => {
const url = getEightBfRoundtableBackUrl();
if (!url) {
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
return;
}
window.location.href = url;
const route = parentReturnRoute(
searchParams.get("parentReturnTo")
?? searchParams.get("parentRoute")
?? searchParams.get("returnTo")
?? "/list",
);
if (requestEightBfParentReturn(route)) return;
toast.warning("尚未配置返回地址,请在 runtime-config.js 填写 VITE_8BF_ROUNDTABLE_BACK_URL");
};
return (
<button
@@ -301,6 +313,10 @@ function EightBfAgentWorkspace({
step3Summary: DraftStep3SummarySnapshot | null;
}) {
const { agents, isLoading } = useAgents();
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const seats = chain?.seats ?? [];
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentKeyword, setAgentKeyword] = useState("");
@@ -400,7 +416,7 @@ function EightBfAgentWorkspace({
onAskQuestion={() => toast.info("请在中间对话区向任务理解智能体发起问题")}
showRecentChats={false}
avatarSrc={agentConfigLogo}
positionName={chain?.title || "8BF 业务链"}
positionName={positionNameById(selectedSeat?.position_id)}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
showCommonQuestions
@@ -0,0 +1,128 @@
import { useEffect } from "react";
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
import { getEightBfRoundtableBackUrl } from "@/eightbf-roundtable/api/task-context";
export const EIGHTBF_PARENT_RETURN_SOURCE = "deerflow-8bf-roundtable";
export const EIGHTBF_PARENT_RETURN_TYPE = "deerflow:8bf-roundtable:return";
export const EIGHTBF_BRIDGE_HOST_SOURCE = "code-zk-roundtable-host";
export const EIGHTBF_BRIDGE_INIT_TYPE = "code-zk:8bf-roundtable-bridge:init";
export const EIGHTBF_BRIDGE_READY_TYPE = "deerflow:8bf-roundtable:bridge-ready";
export interface EightBfParentReturnMessage {
source: typeof EIGHTBF_PARENT_RETURN_SOURCE;
type: typeof EIGHTBF_PARENT_RETURN_TYPE;
payload: {
route: string | null;
fallback: "history-back";
};
}
type EightBfBridgeInitMessage = {
source: typeof EIGHTBF_BRIDGE_HOST_SOURCE;
type: typeof EIGHTBF_BRIDGE_INIT_TYPE;
};
/**
* `parentReturnTo` belongs to the embedding application. Keep it a route/hash
* path so same-origin fallback cannot become an open redirect.
*/
export function parentReturnRoute(value: string | null | undefined): string | null {
const route = value?.trim();
if (!route || route.length > 2048) return null;
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
return null;
}
return route;
}
function isInIframe(): boolean {
if (typeof window === "undefined") return false;
try {
return window.parent !== window;
} catch {
return true;
}
}
const embeddingBridgePort = { current: null as MessagePort | null };
/**
* Accept a MessagePort from code-zk. Wujie can lift this iframe into the
* outer host document, so `window.parent` is no longer code-zk. The port
* still talks to the Vue page that created the channel.
*/
export function useEightBfParentReturnBridge(): void {
useEffect(() => {
const receiveBridgePort = (event: MessageEvent<EightBfBridgeInitMessage>) => {
const message = event.data;
const bridgePort = event.ports?.[0];
if (
message?.source !== EIGHTBF_BRIDGE_HOST_SOURCE
|| message?.type !== EIGHTBF_BRIDGE_INIT_TYPE
|| !bridgePort
) {
return;
}
embeddingBridgePort.current?.close();
embeddingBridgePort.current = bridgePort;
bridgePort.start?.();
bridgePort.postMessage({
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_BRIDGE_READY_TYPE,
});
};
window.addEventListener("message", receiveBridgePort);
return () => {
window.removeEventListener("message", receiveBridgePort);
embeddingBridgePort.current?.close();
embeddingBridgePort.current = null;
};
}, []);
}
/**
* Ask the embedding Vue host to leave this iframe.
*
* Topology: Wujie host → code-zk → DeerFlow iframe. Wujie may reparent the
* iframe so native `window.parent.postMessage` never reaches code-zk.
* MessageChannel is the channel that survives that. Native postMessage is
* only the fallback for a plain iframe.
*/
export function requestEightBfParentReturn(route: string | null): boolean {
const message: EightBfParentReturnMessage = {
source: EIGHTBF_PARENT_RETURN_SOURCE,
type: EIGHTBF_PARENT_RETURN_TYPE,
payload: { route, fallback: "history-back" },
};
if (typeof window === "undefined") return false;
const bridgePort = embeddingBridgePort.current;
if (bridgePort) {
try {
bridgePort.postMessage(message);
return true;
} catch {
// Host unmounted/reloaded. Fall through to native bridges.
}
}
if (isInIframe()) {
window.parent.postMessage(message, "*");
return true;
}
if (isWujieEnv()) {
emitWujieEvent("8bf-roundtable:return", message.payload);
return true;
}
const url = getEightBfRoundtableBackUrl().trim();
if (url) {
window.location.href = url;
return true;
}
return false;
}
@@ -25,6 +25,7 @@ import { PositionBusinessChainFlow } from "@/position-roundtable/components/Posi
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { useEightBfParentReturnBridge } from "@/eightbf-roundtable/lib/parent-return";
import {
listBusinessMappings,
updateBusinessMapping,
@@ -44,6 +45,7 @@ const BUSINESS_CODE = "8BF";
* 不引用岗位会商的页面、会话或接口。
*/
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
useEightBfParentReturnBridge();
const auth = getStoredAuth();
const [searchParams] = useSearchParams();
const taskId = useMemo(
+2 -2
View File
@@ -773,9 +773,9 @@ export default function ChatPage() {
!compactLanding &&
"-translate-y-[calc(50vh-96px)]",
compactLanding
? "max-w-(--container-width-xl)"
? "max-w-[calc(var(--container-width-xl)+150px)]"
: isLandingState
? "max-w-(--container-width-md)"
? "max-w-[calc(var(--container-width-md)+150px)]"
: "max-w-(--container-width-lg)",
)}
>
+10 -41
View File
@@ -79,12 +79,10 @@ import {
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import {
COMPACT_MENU_TYPES,
COMPACT_MENU_TYPE_LABELS,
getRuntimeCompactMenuType,
type CompactMenuOverride,
type CompactMenuPlacement,
type CompactMenuType,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
@@ -510,7 +508,7 @@ export default function MenuManagementPage() {
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
label: "简洁模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
@@ -779,7 +777,7 @@ export default function MenuManagementPage() {
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
简洁模式
</button>
</div>
);
@@ -980,7 +978,8 @@ export default function MenuManagementPage() {
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const runtimeType = getRuntimeCompactMenuType();
const [menuType, setMenuType] = useState<CompactMenuType>(runtimeType);
// 不再提供 A/B 切换:固定编辑当前网页(runtime-config.js VITE_COMPACT_MENU_TYPE)使用的那套。
const menuType = runtimeType;
const { data: overrides = [], isLoading } = useCompactMenuOverrides(menuType);
const { save } = useCompactMenuOverrideActions(menuType);
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
@@ -1038,23 +1037,17 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success(`简介菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
toast.success(`简洁菜单配置已保存(${COMPACT_MENU_TYPE_LABELS[menuType]})`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简介模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
if (!confirm(`确定要重置${COMPACT_MENU_TYPE_LABELS[menuType]}的简洁模式菜单为默认布局吗?保存后将清除该类型的自定义配置。`)) return;
setRows(compactRowsFromTree(baseTree, []));
};
const switchMenuType = (next: CompactMenuType) => {
if (next === menuType) return;
if (dirty && !confirm("当前类型有未保存的修改,切换后将丢失。确定切换吗?")) return;
setMenuType(next);
};
return (
<>
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
@@ -1066,10 +1059,10 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
<div>
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
<Sparkles className="size-3" />
仅简介模式生效
仅简洁模式生效
</span>
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
简介菜单管理
简洁菜单管理
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
{rows.length} 个项目
</span>
@@ -1095,35 +1088,11 @@ function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="inline-flex rounded-2xl border border-slate-200 bg-slate-50 p-1 text-sm font-semibold">
{COMPACT_MENU_TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => switchMenuType(type)}
className={`rounded-xl px-4 py-1.5 transition-all ${
menuType === type ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
{COMPACT_MENU_TYPE_LABELS[type]}
{runtimeType === type ? (
<span className={`ml-1.5 text-[10px] font-medium ${menuType === type ? "text-white/80" : "text-slate-400"}`}>
当前网页
</span>
) : null}
</button>
))}
</div>
</div>
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span>
这里配置的是简介模式工作区左侧菜单,按类型 A / B 分套保存。两套网页在{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">runtime-config.js</code> 里填写{" "}
<code className="rounded bg-slate-200/70 px-1 py-0.5 text-xs">VITE_COMPACT_MENU_TYPE</code> 为 A 或 B
即可加载对应配置;全量模式菜单不受影响。当前网页正在使用
{COMPACT_MENU_TYPE_LABELS[runtimeType]}。
这里配置的是简洁模式工作区左侧菜单,保存后仅对当前网页使用的
{COMPACT_MENU_TYPE_LABELS[runtimeType]}生效(两套部署各改各的);全量模式菜单不受影响。
</span>
</p>
</div>
@@ -40,7 +40,6 @@ import {
MessageSquareText,
PanelRightOpen,
RotateCcw,
SlidersHorizontal,
Sparkles,
Telescope,
Trash2,
@@ -126,6 +125,7 @@ import {
ReportOutlineEditor,
} from "./ReportConfigCard";
import { ResearchSandboxPanel } from "./ResearchSandboxPanel";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox";
const COLLECTOR_AGENT_ID = "deep-research-collector";
@@ -1764,9 +1764,15 @@ function DeepResearchWorkbenchInner() {
<ImagePlus className="h-3.5 w-3.5" />配图
</button>
)}
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setSettingsOpen(true)} disabled={Boolean(isFollowUp) || jobWriting}>
<SlidersHorizontal className="mr-1.5 h-3.5 w-3.5" />高级配置
</Button>
<WorkflowConfigButton
variant="ghost"
className="h-7 px-2 text-xs"
disabled={Boolean(isFollowUp) || jobWriting}
appliedContent={advancedOptions.customOutline}
onApply={(content) =>
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
}
/>
</div>
</div>
<PromptInput onSubmit={({ text }) => handleComposerSubmit(text)}>
@@ -2654,6 +2660,18 @@ function AdvancedResearchSettingsDialog({
onChange={(value) => onChange("customOutline", value)}
/>
{/* 工作流配置:报告结构库(后台 /api/report-structures),选中卡片即填充上方报告结构 */}
<div className="flex items-center justify-between gap-3 rounded-xl border border-border p-3">
<span className="text-xs leading-5 text-muted-foreground">
<span className="block text-sm font-medium text-foreground">报告结构库</span>
在「工作流配置」中管理并选择报告结构;选中卡片后自动填充到上方报告结构。
</span>
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(content) => onChange("customOutline", content)}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer gap-3 rounded-xl border p-3">
<input
@@ -32,6 +32,7 @@ import {
type ResearchMode,
type ResearchTone,
} from "@/strategy-components/api/deep-research";
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
const MODE_LABELS: Record<ResearchMode, string> = {
basic: "标准",
@@ -473,6 +474,12 @@ export function ReportConfigCard({
onChange={(value) => update("customOutline", value)}
/>
</div>
<div className="flex justify-end sm:col-span-2">
<WorkflowConfigButton
appliedContent={options.customOutline}
onApply={(value) => update("customOutline", value)}
/>
</div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-border/70 bg-background/60 p-2.5 text-[11px] leading-4 sm:col-span-2">
<input
type="checkbox"
@@ -0,0 +1,549 @@
"use client";
/**
* 工作流配置(报告结构库)弹窗 + 触发按钮。
*
* 深度研究「高级配置」区的人口:按钮打开大弹窗,弹窗内两视图——
* - 列表视图:报告结构卡片网格(后台 /api/report-structures,全局共享,
* 全新部署自动播种「通用分析结构 / 递进式论证结构」),支持 启用/停用 筛选
* 与 标题 筛选;卡片上可 switch 启停、编辑、删除(二次确认),并显示
* 最后修改时间与创建用户名;新增时不填状态(服务端强制默认启用)。
* - 详情视图:点击卡片即「选择」该结构(内容回填到高级配置的报告结构
* custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。
*/
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
ChevronLeft,
Loader2,
Pencil,
Plus,
Search,
Trash2,
Workflow,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
type ReportStructure,
type ReportStructureStatusFilter,
useReportStructures,
useReportStructureMutations,
} from "@/strategy-components/api/report-structures";
// ── shared helpers ───────────────────────────────────────────────────────────
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
/** 创建用户名:本部署账号是邮箱,取 @ 前缀(与个人信息页「用户名」一致)。 */
function creatorLabel(structure: ReportStructure): string {
if (!structure.createdBy) return "—";
return structure.createdBy.split("@")[0] || structure.createdBy;
}
// ── main dialog ──────────────────────────────────────────────────────────────
export function WorkflowConfigDialog({
open,
onOpenChange,
/** 当前已应用的报告结构内容(高级配置的 custom_outline),用于高亮「当前使用」。 */
appliedContent,
/** 点击卡片选择结构时回填内容。 */
onApply,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
onApply?: (content: string) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
const [titleFilter, setTitleFilter] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const [editorTarget, setEditorTarget] = useState<ReportStructure | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ReportStructure | null>(null);
// 启用/停用筛选走服务端;标题筛选在本地做(即时、免抖动)。
const listQuery = useReportStructures({ status: statusFilter });
const mutations = useReportStructureMutations();
// 重新打开时回到列表视图。
useEffect(() => {
if (open) setDetailId(null);
}, [open]);
const structures = listQuery.data ?? [];
const visible = useMemo(() => {
const needle = titleFilter.trim().toLowerCase();
if (!needle) return structures;
return structures.filter((s) => s.title.toLowerCase().includes(needle));
}, [structures, titleFilter]);
const appliedStructureId = useMemo(() => {
const trimmed = (appliedContent ?? "").trim();
if (!trimmed) return null;
return structures.find((s) => s.content.trim() === trimmed)?.id ?? null;
}, [appliedContent, structures]);
const detail = useMemo(
() => structures.find((s) => s.id === detailId) ?? null,
[structures, detailId],
);
const handleSelect = (structure: ReportStructure) => {
onApply?.(structure.content);
setDetailId(structure.id);
toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`);
};
const handleToggleEnabled = (structure: ReportStructure, enabled: boolean) => {
mutations.toggleEnabled.mutate(
{ id: structure.id, enabled },
{
onSuccess: () => toast.success(`「${structure.title}」已${enabled ? "启用" : "停用"}`),
onError: (err) => toast.error(err instanceof Error ? err.message : "操作失败"),
},
);
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await mutations.remove.mutateAsync(deleteTarget.id);
toast.success(`已删除「${deleteTarget.title}」`);
if (detailId === deleteTarget.id) setDetailId(null);
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "删除失败");
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[85vh] max-h-[85vh] w-[calc(100vw-3rem)] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
>
{/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */}
{!detail ? (
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold">工作流配置 · 报告结构</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="关闭"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={titleFilter}
onChange={(event) => setTitleFilter(event.target.value)}
placeholder="按标题筛选"
className="h-8 pl-8 text-xs"
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as ReportStructureStatusFilter)}
>
<SelectTrigger className="h-8 w-28 shrink-0 text-xs" aria-label="启用状态筛选">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="enabled">已启用</SelectItem>
<SelectItem value="disabled">已停用</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Badge variant="outline" className="h-6 text-[10px]">{visible.length} 套</Badge>
<Button
size="sm"
className="h-8"
onClick={() => {
setEditorTarget(null);
setEditorOpen(true);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />新增结构
</Button>
</div>
</div>
</div>
) : (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-4">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="outline" className="shrink-0">报告结构</Badge>
<h2 className="truncate text-base font-semibold">{detail.title}</h2>
{detail.id === appliedStructureId && (
<Badge className="shrink-0">当前使用</Badge>
)}
{!detail.enabled && <Badge variant="secondary" className="shrink-0">已停用</Badge>}
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setDetailId(null)}>
<ChevronLeft className="mr-1 h-4 w-4" />返回
</Button>
</div>
)}
{/* body */}
{!detail ? (
<ScrollArea className="min-h-0 flex-1">
<div className="p-5">
{listQuery.isLoading ? (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />加载中…
</div>
) : listQuery.isError ? (
<div className="py-16 text-center text-sm text-destructive">
{(listQuery.error as Error)?.message || "加载报告结构失败"}
</div>
) : visible.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-sm text-muted-foreground">
<Workflow className="h-10 w-10 text-muted-foreground/30" />
{titleFilter || statusFilter !== "all" ? "没有符合条件的报告结构" : "暂无报告结构,点击右上角「新增结构」创建"}
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{visible.map((structure) => (
<StructureCard
key={structure.id}
structure={structure}
applied={structure.id === appliedStructureId}
onOpen={() => handleSelect(structure)}
onEdit={(event) => {
event.stopPropagation();
setEditorTarget(structure);
setEditorOpen(true);
}}
onDelete={(event) => {
event.stopPropagation();
setDeleteTarget(structure);
}}
onToggleEnabled={(enabled) => handleToggleEnabled(structure, enabled)}
toggling={mutations.toggleEnabled.isPending}
/>
))}
</div>
)}
</div>
</ScrollArea>
) : (
<ScrollArea className="min-h-0 flex-1">
<div className="mx-auto max-w-3xl p-5">
{detail.name && (
<p className="mb-1 text-xs text-muted-foreground">描述:{detail.name}</p>
)}
<p className="mb-4 text-xs text-muted-foreground">
最后修改:{formatDateTime(detail.updatedAt)} · 创建人:{creatorLabel(detail)}
</p>
<article className="prose prose-sm dark:prose-invert max-w-none rounded-xl border border-border bg-card p-5">
<ReactMarkdown components={{ img: () => null }}>{detail.content}</ReactMarkdown>
</article>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
{/* 新增 / 编辑弹窗(嵌套) */}
<StructureEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
target={editorTarget}
onSubmit={async (values) => {
try {
if (editorTarget) {
await mutations.update.mutateAsync({ id: editorTarget.id, patch: values });
toast.success("报告结构已保存");
} else {
await mutations.create.mutateAsync(values);
toast.success("已新增报告结构(默认启用)");
}
setEditorOpen(false);
setEditorTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
}}
submitting={mutations.create.isPending || mutations.update.isPending}
/>
{/* 删除二次确认(嵌套) */}
<Dialog open={deleteTarget !== null} onOpenChange={(next) => !next && setDeleteTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>删除报告结构</DialogTitle>
<DialogDescription>
确认删除「{deleteTarget?.title}」?删除后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={mutations.remove.isPending}>
取消
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={mutations.remove.isPending}>
{mutations.remove.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
// ── structure card ───────────────────────────────────────────────────────────
function StructureCard({
structure,
applied,
onOpen,
onEdit,
onDelete,
onToggleEnabled,
toggling,
}: {
structure: ReportStructure;
applied: boolean;
onOpen: () => void;
onEdit: (event: React.MouseEvent) => void;
onDelete: (event: React.MouseEvent) => void;
onToggleEnabled: (enabled: boolean) => void;
toggling: boolean;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen();
}
}}
className={`group cursor-pointer rounded-xl border p-4 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring ${
applied
? "border-primary/50 bg-primary/5"
: "border-border bg-card hover:border-primary/30 hover:bg-accent/40"
}`}
>
<div className="mb-1.5 flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-semibold">{structure.title}</span>
{applied && <Badge className="h-5 shrink-0 text-[10px]">当前使用</Badge>}
<Badge variant={structure.enabled ? "default" : "outline"} className={`h-5 shrink-0 text-[10px] ${structure.enabled ? "" : "text-muted-foreground"}`}>
{structure.enabled ? "启用" : "停用"}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title="编辑"
aria-label={`编辑 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="删除"
aria-label={`删除 ${structure.title}`}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{structure.name && <p className="mb-2 truncate text-xs text-muted-foreground">{structure.name}</p>}
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-2.5">
<div className="min-w-0 text-[11px] leading-4 text-muted-foreground">
<p className="truncate">最后修改:{formatDateTime(structure.updatedAt)}</p>
<p className="truncate">创建人:{creatorLabel(structure)}</p>
</div>
<div
className="flex shrink-0 items-center gap-1.5"
onClick={(event) => event.stopPropagation()}
role="presentation"
>
<span className="text-[11px] text-muted-foreground">{structure.enabled ? "启用" : "停用"}</span>
<Switch
checked={structure.enabled}
disabled={toggling}
aria-label={`${structure.enabled ? "停用" : "启用"} ${structure.title}`}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
</div>
);
}
// ── editor dialog(新增 / 编辑共用;状态不允许填写,创建默认启用) ─────────────
function StructureEditorDialog({
open,
onOpenChange,
target,
onSubmit,
submitting,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
target: ReportStructure | null;
onSubmit: (values: { title: string; name: string; content: string }) => Promise<void>;
submitting: boolean;
}) {
const [title, setTitle] = useState("");
const [name, setName] = useState("");
const [content, setContent] = useState("");
useEffect(() => {
if (open) {
setTitle(target?.title ?? "");
setName(target?.name ?? "");
setContent(target?.content ?? "");
}
}, [open, target]);
const valid = title.trim().length > 0 && content.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] w-[calc(100vw-2rem)] max-w-2xl overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{target ? "编辑报告结构" : "新增报告结构"}</DialogTitle>
<DialogDescription>
{target ? "修改后将刷新「最后修改时间」。" : "新增的报告结构默认为启用状态。"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">标题</span>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={256}
placeholder="例如:行业研究通用结构"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">描述</span>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={256}
placeholder="可选,一句话描述这套结构的适用场景"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">接口内容</span>
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
maxLength={20000}
placeholder={"报告结构正文,支持 Markdown 标题,例如:\n# 引言\n# 一、事实与背景\n## 1.1 研究范围"}
className="min-h-[260px] resize-y font-mono text-xs"
/>
<span className="mt-1 block text-[11px] text-muted-foreground">
选中卡片时,该内容会填充到高级配置的「报告结构」。
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
取消
</Button>
<Button disabled={!valid || submitting} onClick={() => void onSubmit({ title: title.trim(), name: name.trim(), content: content })}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── trigger button(自带弹窗开合状态) ────────────────────────────────────────
export function WorkflowConfigButton({
appliedContent,
onApply,
className,
variant = "outline",
size = "sm",
disabled = false,
}: {
appliedContent?: string;
onApply: (content: string) => void;
className?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
return (
<>
<Button variant={variant} size={size} className={className} disabled={disabled} onClick={() => setOpen(true)}>
<Workflow className="mr-1.5 h-3.5 w-3.5" />
工作流配置
</Button>
<WorkflowConfigDialog
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
onApply={onApply}
/>
</>
);
}
@@ -4,7 +4,7 @@
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Check, Search } from "lucide-react";
import {
@@ -13,8 +13,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import {
positionNameById,
syncPositionOptionsFromRoles,
} from "@/position-roundtable/lib/positions";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
@@ -72,6 +78,10 @@ export function PositionBusinessChainFlow({
submittedSeatKeys,
onSeatClick,
}: PositionBusinessChainFlowProps) {
const { data: positionRoles = [] } = usePositionRoles();
useEffect(() => {
syncPositionOptionsFromRoles(positionRoles);
}, [positionRoles]);
const [dialogOpen, setDialogOpen] = useState(false);
const submittedKeys = new Set(
(submittedSeatKeys ?? []).map((key) => key.trim().toLowerCase()).filter(Boolean),
@@ -251,8 +261,8 @@ function BusinessChainCanvas({
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
title={desensitize(seat.name || seat.agent_id)}
description={desensitize(positionNameById(seat.position_id))}
accent={accent}
width={nodeWidth}
compact={embedded}
@@ -88,3 +88,31 @@ export function resolvePositionId(
export const positionNameById = (positionId: string | null | undefined): string =>
runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位";
/** Sync the flowchart/agent-config name lookup from the live 岗位角色 directory. */
export function syncPositionOptionsFromRoles(
roles: ReadonlyArray<{
id: string;
name: string;
description: string;
roleType?: string;
enabled?: boolean;
isBuiltin?: boolean;
sortOrder?: number;
}>,
): void {
if (!roles.length) return;
setRuntimePositionOptions(
[...roles]
.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
isIntentPosition: role.roleType === "intent",
enabled: role.enabled,
isBuiltin: role.isBuiltin,
sortOrder: role.sortOrder,
})),
);
}
@@ -53,6 +53,8 @@ export interface ChainUpdatePayload {
gatherFirst?: boolean;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
/** 启停开关:true 启用 / false 停用;omit 不改。仅 owner / 管理员生效。 */
enabled?: boolean;
}
/**
@@ -88,6 +90,7 @@ interface ServerChain {
seat_mode?: string | null;
gather_first?: boolean;
is_public?: boolean;
enabled?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
@@ -106,6 +109,7 @@ function toChain(s: ServerChain): BusinessChain {
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false,
enabled: s.enabled ?? true,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
@@ -141,6 +145,7 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
if (p.isPublic !== undefined) out.is_public = p.isPublic;
if (p.enabled !== undefined) out.enabled = p.enabled;
return out;
}
@@ -171,6 +176,11 @@ export async function setChainPublic(id: string, isPublic: boolean): Promise<Bus
return updateChain(id, { isPublic });
}
/** 启用 / 停用一条链条(仅 owner / 管理员生效)。返回更新后的链条,404 返回 null。 */
export async function setChainEnabled(id: string, enabled: boolean): Promise<BusinessChain | null> {
return updateChain(id, { enabled });
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
@@ -214,10 +224,22 @@ export async function updateChain(id: string, patch: ChainUpdatePayload): Promis
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "update chain");
const chain = toChain(data);
const enabledToggle = patch.enabled === true || patch.enabled === false;
writeYLog({
optType: patch.isPublic === true ? OptType.enabled : patch.isPublic === false ? OptType.disabled : OptType.edit,
optDetail:
patch.isPublic === true
optType: enabledToggle
? patch.enabled
? OptType.enabled
: OptType.disabled
: patch.isPublic === true
? OptType.enabled
: patch.isPublic === false
? OptType.disabled
: OptType.edit,
optDetail: enabledToggle
? patch.enabled
? "CM助手-全量-业务链条-启用"
: "CM助手-全量-业务链条-停用"
: patch.isPublic === true
? "CM助手-全量-业务链条-发布"
: patch.isPublic === false
? "CM助手-全量-业务链条-取消发布"
@@ -9,9 +9,10 @@
* 卡片席位名优先用实时 agent 数据(`listAgents()`,与 `BusinessChainPicker` 一致),
* agent 改名后此弹窗也能同步显示;解析不到才回退到落库快照(`seat.name`)。
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
ClockIcon,
GlobeIcon,
LayersIcon,
Loader2Icon,
@@ -28,14 +29,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { getStoredAuth } from "@/core/auth";
import { listAgents } from "@/core/agents";
import { listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { listChains, setChainEnabled, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, isChainEnabled, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainListDialogProps {
@@ -57,6 +59,8 @@ export interface BusinessChainListBodyProps {
emptyHint?: string;
}
export type EnabledFilter = "all" | "enabled" | "disabled";
/**
* 业务链条列表主体(筛选 tab + 滚动卡片列表)。
*
@@ -76,7 +80,10 @@ export function BusinessChainListBody({
const isAdmin = getStoredAuth()?.system_role === "admin";
// 筛选:我的 / 公共(已发布)/(管理员)全部。
const [scope, setScope] = useState<ChainScope>("mine");
// 启停筛选:全部 / 启用 / 停用。默认全部,关掉开关后卡片仍留在当前列表。
const [enabledFilter, setEnabledFilter] = useState<EnabledFilter>("all");
const [publishingId, setPublishingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
@@ -137,8 +144,12 @@ export function BusinessChainListBody({
async (chain: BusinessChain) => {
setPublishingId(chain.id);
try {
await setChainPublic(chain.id, !chain.isPublic);
await refresh(scope);
const updated = await setChainPublic(chain.id, !chain.isPublic);
if (updated) {
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} else {
await refresh(scope);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -148,25 +159,69 @@ export function BusinessChainListBody({
[refresh, scope],
);
const handleToggleEnabled = useCallback(
async (chain: BusinessChain) => {
setTogglingId(chain.id);
try {
const updated = await setChainEnabled(chain.id, !isChainEnabled(chain));
if (updated) {
setChains((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
} else {
await refresh(scope);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setTogglingId(null);
}
},
[refresh, scope],
);
const visibleChains = useMemo(() => {
if (enabledFilter === "enabled") return chains.filter((c) => isChainEnabled(c));
if (enabledFilter === "disabled") return chains.filter((c) => !isChainEnabled(c));
return chains;
}, [chains, enabledFilter]);
return (
<>
{/* 筛选:我的 / 公共 /(管理员)全部 */}
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
</button>
))}
{/* 筛选:我的 / 公共 /(管理员)全部 + 全部 / 启用 / 停用 */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
scope === s
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
</button>
))}
</div>
<div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{(["all", "enabled", "disabled"] as EnabledFilter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setEnabledFilter(f)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition",
enabledFilter === f
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{f === "all" ? "全部状态" : f === "enabled" ? "启用" : "停用"}
</button>
))}
</div>
</div>
<div className="custom-scrollbar -mr-1 min-h-0 flex-1 overflow-y-auto py-3 pr-1">
@@ -179,30 +234,40 @@ export function BusinessChainListBody({
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
) : visibleChains.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">
{scope === "public" ? "还没有公共链条" : "还没有业务链条"}
{enabledFilter !== "all"
? enabledFilter === "enabled"
? "没有启用的业务链条"
: "没有停用的业务链条"
: scope === "public"
? "还没有公共链条"
: "还没有业务链条"}
</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
{scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: emptyHint ??
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
{enabledFilter !== "all"
? "试试切换「全部状态」,或到卡片上用开关切换启停。"
: scope === "public"
? "公共链条是其他账号(或你自己)发布出来、所有人可见的链条。去「我的」里发布一条试试。"
: emptyHint ??
"业务链条是可复用的有序智能体编排。点击下方「配置」去创建第一条链条。"}
</p>
</div>
) : (
<div className="space-y-3.5">
{chains.map((chain) => (
{visibleChains.map((chain) => (
<ChainRow
key={chain.id}
chain={chain}
canManage={isAdmin || chain.isMine !== false}
nameById={nameById}
publishing={publishingId === chain.id}
toggling={togglingId === chain.id}
onSettings={() => onConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
onToggleEnabled={() => handleToggleEnabled(chain)}
/>
))}
</div>
@@ -252,22 +317,33 @@ function ChainRow({
canManage,
nameById,
publishing,
toggling,
onSettings,
onTogglePublic,
onToggleEnabled,
}: {
chain: BusinessChain;
/** 能否编辑 / 发布(本人链条 或 管理员)。 */
/** 能否编辑 / 发布 / 启停(本人链条 或 管理员)。 */
canManage: boolean;
nameById: Map<string, string>;
publishing: boolean;
toggling: boolean;
onSettings: () => void;
onTogglePublic: () => void;
onToggleEnabled: () => void;
}) {
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
const enabled = isChainEnabled(chain);
const updatedLabel = formatUpdatedAt(chain.updatedAt);
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
<div
className={cn(
"rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40",
!enabled && "opacity-70",
)}
>
{/* 标题行 + 公共/停用徽标 + 启停开关 / 发布 / 设置按钮 */}
<div className="flex items-center gap-2.5">
<div className="truncate text-base font-semibold">{chain.title || "未命名链条"}</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
@@ -279,7 +355,26 @@ function ChainRow({
公共
</span>
)}
{!enabled && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
已停用
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 启停:本人的链条或管理员可操作。 */}
{canManage && (
<label className="flex items-center gap-1.5 pr-1 text-xs text-muted-foreground">
<Switch
checked={enabled}
disabled={toggling}
onCheckedChange={() => onToggleEnabled()}
aria-label={enabled ? "停用该业务链条" : "启用该业务链条"}
/>
<span className={cn("min-w-[2em]", enabled ? "text-foreground" : "text-muted-foreground")}>
{enabled ? "启用" : "停用"}
</span>
</label>
)}
{/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
{canManage && (
<Button
@@ -312,13 +407,20 @@ function ChainRow({
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
{(ownerName || updatedLabel) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{ownerName && (
<div className="flex items-center gap-1" title={chain.ownerName}>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{updatedLabel && (
<div className="flex items-center gap-1" title={chain.updatedAt}>
<ClockIcon className="size-3.5 shrink-0" />
<span>最后修改:{updatedLabel}</span>
</div>
)}
</div>
)}
@@ -401,3 +503,21 @@ function ChainSeatsFlow({
</div>
);
}
/** 后端 `updated_at` 形如 `2026-08-19T17:50:00+08:00`;展示到分钟。 */
function formatUpdatedAt(iso: string | undefined): string {
if (!iso) return "";
const date = new Date(iso);
if (!Number.isNaN(date.getTime())) {
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
const s = iso.replace("T", " ");
return s.length >= 16 ? s.slice(0, 16) : s;
}
@@ -34,6 +34,7 @@ import { listChains, type ChainScope } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
isChainEnabled,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
@@ -77,7 +78,7 @@ export function BusinessChainPicker({
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
setChains(chainList);
setChains(chainList.filter(isChainEnabled));
if (agents) {
const candidates = filterRecommendCandidates(agents);
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
@@ -95,6 +95,11 @@ export interface BusinessChain {
gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/**
* 启停状态。缺省 / undefined = 启用(存量链条无该字段时按启用处理)。
* 停用后管理弹窗仍可见,选链弹窗 / 公共入口不再选用。
*/
enabled?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
@@ -151,6 +156,11 @@ export interface SeatSelectionResult {
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 20;
/** 启停状态:缺省 / undefined 视为启用(存量链条无该字段)。 */
export function isChainEnabled(chain: Pick<BusinessChain, "enabled">): boolean {
return chain.enabled !== false;
}
/**
* 由一条业务链条 + 当前候选智能体,构造 `SeatSelectionResult`。
*
@@ -134,6 +134,7 @@ import {
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
isChainEnabled,
type BusinessChain,
type ChainSeat,
type SeatSelectionResult,
@@ -3395,7 +3396,8 @@ function RoundtableEntryRail({
void listChains("public")
.then((all) => {
if (!cancelled) {
setChains(lockBusinessChain ? all.filter((chain) => chain.id === lockedChainId) : all);
const usable = all.filter(isChainEnabled);
setChains(lockBusinessChain ? usable.filter((chain) => chain.id === lockedChainId) : usable);
}
})
.catch(() => {
@@ -0,0 +1,180 @@
/**
* 深度研究「工作流配置 / 报告结构」API 客户端 + React Query hooks。
*
* 后端:`/api/report-structures`(见 app/gateway/routers/report_structures.py)。
* 全局共享、无用户隔离;新增永远默认启用(服务端强制,不收 enabled 字段)。
* snake_case ⇄ camelCase 在此边界收敛。
*/
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface ReportStructure {
id: string;
/** 标题:卡片主标签,也是列表「标题筛选」的目标。 */
title: string;
/** 描述:一句话说明这套结构的适用场景(存储列沿用 name)。 */
name: string;
/** 接口内容:选中卡片后应用到报告结构(custom_outline)的 Markdown 文本。 */
content: string;
enabled: boolean;
/** 创建用户名(本部署为邮箱;系统内置种子为“系统内置”)。 */
createdBy: string | null;
createdAt: string | null;
/** 最后修改时间(编辑、启停都会刷新)。 */
updatedAt: string | null;
}
export type ReportStructureInput = {
title: string;
name: string;
content: string;
};
export type ReportStructurePatch = Partial<ReportStructureInput>;
export type ReportStructureStatusFilter = "all" | "enabled" | "disabled";
const BASE = () => `${getBackendBaseURL()}/api/report-structures`;
interface ServerStructure {
id: string;
title: string;
name: string;
content: string;
enabled: boolean;
created_by: string | null;
created_at: string | null;
updated_at: string | null;
}
function toStructure(s: ServerStructure): ReportStructure {
return {
id: s.id,
title: s.title,
name: s.name ?? "",
content: s.content,
enabled: s.enabled ?? true,
createdBy: s.created_by ?? null,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${ctx} failed (${res.status})`;
throw new Error(detail);
}
return (await res.json()) as T;
}
export async function listReportStructures(params: {
status?: ReportStructureStatusFilter;
title?: string;
} = {}): Promise<ReportStructure[]> {
const qs = new URLSearchParams();
if (params.status === "enabled") qs.set("enabled", "true");
if (params.status === "disabled") qs.set("enabled", "false");
const title = params.title?.trim();
if (title) qs.set("title", title);
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{ structures: ServerStructure[] }>(res, "list report structures");
return (data.structures ?? []).map(toStructure);
}
export async function createReportStructure(input: ReportStructureInput): Promise<ReportStructure> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: input.title, name: input.name, content: input.content }),
});
return toStructure(await readJson<ServerStructure>(res, "create report structure"));
}
export async function updateReportStructure(
id: string,
patch: ReportStructurePatch,
): Promise<ReportStructure> {
const body: Record<string, unknown> = {};
if (patch.title !== undefined) body.title = patch.title;
if (patch.name !== undefined) body.name = patch.name;
if (patch.content !== undefined) body.content = patch.content;
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return toStructure(await readJson<ServerStructure>(res, "update report structure"));
}
export async function setReportStructureEnabled(id: string, enabled: boolean): Promise<ReportStructure> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/enabled`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
return toStructure(await readJson<ServerStructure>(res, "toggle report structure"));
}
export async function deleteReportStructure(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
// 204 无响应体,不能再走 readJson 的 res.json()。
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { detail?: unknown } | null;
throw new Error(
data && typeof data.detail === "string" ? data.detail : `delete report structure failed (${res.status})`,
);
}
}
// ── React Query hooks(工作流配置弹窗内各视图共用缓存) ──────────────────────
const REPORT_STRUCTURES_KEY = "report-structures";
export function useReportStructures(params: { status?: ReportStructureStatusFilter; title?: string } = {}) {
return useQuery({
queryKey: [REPORT_STRUCTURES_KEY, "list", params.status ?? "all", params.title ?? ""],
queryFn: () => listReportStructures(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useReportStructureMutations() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [REPORT_STRUCTURES_KEY] });
const create = useMutation({
mutationFn: (input: ReportStructureInput) => createReportStructure(input),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: ReportStructurePatch }) =>
updateReportStructure(id, patch),
onSuccess: invalidate,
});
const toggleEnabled = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
setReportStructureEnabled(id, enabled),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => deleteReportStructure(id),
onSuccess: invalidate,
});
return { create, update, toggleEnabled, remove };
}