feat: 会商链条启停与深度研究报告结构,并完善问答展示与 8BF 返回。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
be4851555d
commit
0ce060693b
42 files changed
+5320
-3247
No files matched your search
@@ -52,7 +52,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
|
||||
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
|
||||
// 不影响原多智能体会商使用的任务详情接口。
|
||||
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.sit.4.cn/zl_consumer",
|
||||
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zl_consumer",
|
||||
|
||||
// 8BF 会商页右上角「返回」按钮的跳转地址(当前窗口 window.location.href)。
|
||||
// 部署后直接改 dist/runtime-config.js 即可,无需重新打包。留空则点击时提示未配置。
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -482,6 +482,16 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
### Roundtable business-chain enable/disable
|
||||
|
||||
`roundtable_chains.enabled` (BOOLEAN NOT NULL DEFAULT 1, migration `20260819_02`)
|
||||
is the per-chain 启停 switch. New and existing chains default to **enabled**.
|
||||
Owner or admin can flip it via `PUT /api/roundtable-chains/{id}` `{enabled}`.
|
||||
Disabled chains stay visible in the management list (with an 启用/停用 filter)
|
||||
and still show `updated_at`; the frontend hides them from the picker / public
|
||||
entry rail so they cannot be selected into a roundtable. Tests:
|
||||
`tests/test_roundtable_chain_stages.py` (`test_create_defaults_enabled_then_toggle`).
|
||||
|
||||
### Roundtable business-chain human validation
|
||||
|
||||
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -64,15 +64,20 @@ class AgentResponse(BaseModel):
|
||||
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
|
||||
)
|
||||
|
||||
seat_skill_directive: bool | None = Field(
|
||||
seat_skill_directive: bool | None = Field(
|
||||
default=None,
|
||||
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
|
||||
)
|
||||
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
|
||||
)
|
||||
)
|
||||
|
||||
job_responsibilities: str | None = Field(
|
||||
default=None,
|
||||
description="岗位职责 (config.yaml). Shown above 常用问题 in the agent configuration panel. None/empty = not set.",
|
||||
)
|
||||
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
|
||||
)
|
||||
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
||||
|
||||
@@ -125,12 +130,13 @@ class AgentCreateRequest(BaseModel):
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
||||
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
|
||||
)
|
||||
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
|
||||
job_responsibilities: str | None = Field(default=None, description="岗位职责 persisted to config.yaml. Empty/None = not set.")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
|
||||
)
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
@@ -147,12 +153,13 @@ class AgentUpdateRequest(BaseModel):
|
||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
||||
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
|
||||
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
|
||||
)
|
||||
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
|
||||
job_responsibilities: str | None = Field(default=None, description="Updated 岗位职责 (config.yaml). Empty string clears it.")
|
||||
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=100,
|
||||
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
|
||||
)
|
||||
|
||||
|
||||
class AgentSelectorResponse(BaseModel):
|
||||
@@ -193,38 +200,38 @@ def _current_user_id(request: Request) -> str:
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _is_admin_user(request: Request) -> bool:
|
||||
def _is_admin_user(request: Request) -> bool:
|
||||
"""Return True when the request is authenticated as a system admin."""
|
||||
user = getattr(request.state, "user", None)
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
async def _validate_llmwiki_bindings(
|
||||
request: Request,
|
||||
mapping_ids: list[str] | None,
|
||||
) -> list[str] | None:
|
||||
"""Normalize bindings and require read access for the editing actor."""
|
||||
|
||||
if mapping_ids is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
|
||||
if not normalized:
|
||||
return []
|
||||
store = getattr(request.app.state, "llmwiki_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
for mapping_id in normalized:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
|
||||
return normalized
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
async def _validate_llmwiki_bindings(
|
||||
request: Request,
|
||||
mapping_ids: list[str] | None,
|
||||
) -> list[str] | None:
|
||||
"""Normalize bindings and require read access for the editing actor."""
|
||||
|
||||
if mapping_ids is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
|
||||
if not normalized:
|
||||
return []
|
||||
store = getattr(request.app.state, "llmwiki_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
|
||||
user_id = _current_user_id(request)
|
||||
is_admin = _is_admin_user(request)
|
||||
for mapping_id in normalized:
|
||||
row = await store.get_authorized(
|
||||
mapping_id,
|
||||
user_id,
|
||||
write=False,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
|
||||
return normalized
|
||||
|
||||
|
||||
def _email_local_part(email: str | None) -> str | None:
|
||||
@@ -276,7 +283,7 @@ def _validate_storage_id(agent_id: str) -> str:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _write_agent_config(
|
||||
def _write_agent_config(
|
||||
agent_id: str,
|
||||
*,
|
||||
name: str,
|
||||
@@ -286,9 +293,10 @@ def _write_agent_config(
|
||||
tool_groups: list[str] | None = None,
|
||||
common_questions: list[CommonQuestion] | None = None,
|
||||
template_name: str | None = None,
|
||||
seat_skill_directive: bool | None = None,
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
seat_skill_directive: bool | None = None,
|
||||
job_responsibilities: str | None = None,
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = agent_dir / "config.yaml"
|
||||
@@ -317,19 +325,30 @@ def _write_agent_config(
|
||||
if template_name is not None and template_name.strip():
|
||||
config_data["template_name"] = template_name.strip()
|
||||
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
||||
if seat_skill_directive is not None:
|
||||
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
||||
if llmwiki_knowledge_base_ids is not None:
|
||||
config_data["llmwiki_knowledge_base_ids"] = list(
|
||||
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
|
||||
)
|
||||
if seat_skill_directive is not None:
|
||||
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
||||
if job_responsibilities is not None and job_responsibilities.strip():
|
||||
config_data["job_responsibilities"] = job_responsibilities.strip()
|
||||
if llmwiki_knowledge_base_ids is not None:
|
||||
config_data["llmwiki_knowledge_base_ids"] = list(
|
||||
dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip())
|
||||
)
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
|
||||
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
|
||||
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
|
||||
empty = {
|
||||
"skills": None,
|
||||
"model": None,
|
||||
"tool_groups": None,
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
try:
|
||||
cfg = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
@@ -342,8 +361,9 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"tool_groups": cfg.tool_groups,
|
||||
"common_questions": cfg.common_questions,
|
||||
"template_name": cfg.template_name,
|
||||
"seat_skill_directive": cfg.seat_skill_directive,
|
||||
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
|
||||
"seat_skill_directive": cfg.seat_skill_directive,
|
||||
"job_responsibilities": cfg.job_responsibilities,
|
||||
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
|
||||
}
|
||||
|
||||
|
||||
@@ -504,7 +524,16 @@ def _record_to_response(
|
||||
can happen lazily when the agent is actually opened.
|
||||
"""
|
||||
soul: str | None = None
|
||||
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None, "llmwiki_knowledge_base_ids": None}
|
||||
extras: dict[str, Any] = {
|
||||
"skills": None,
|
||||
"model": None,
|
||||
"tool_groups": None,
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
if include_extras:
|
||||
_ensure_agent_dir_from_record(record)
|
||||
extras = _load_config_extras(record["id"])
|
||||
@@ -528,8 +557,9 @@ def _record_to_response(
|
||||
skills=extras["skills"],
|
||||
common_questions=extras.get("common_questions"),
|
||||
template_name=extras.get("template_name"),
|
||||
seat_skill_directive=extras.get("seat_skill_directive"),
|
||||
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
|
||||
seat_skill_directive=extras.get("seat_skill_directive"),
|
||||
job_responsibilities=extras.get("job_responsibilities"),
|
||||
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
|
||||
featured_order=record.get("featured_order"),
|
||||
)
|
||||
|
||||
@@ -830,7 +860,7 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
||||
summary="Create Agent",
|
||||
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
|
||||
)
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
||||
_require_agents_api_enabled()
|
||||
name = _validate_display_name(request.name)
|
||||
store = get_agent_store(http_request)
|
||||
@@ -839,13 +869,13 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
agent_id = _validate_storage_id(request.id)
|
||||
if await store.get_any(agent_id) is not None:
|
||||
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
llmwiki_ids = await _validate_llmwiki_bindings(
|
||||
http_request,
|
||||
request.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
else:
|
||||
agent_id = uuid4().hex
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
llmwiki_ids = await _validate_llmwiki_bindings(
|
||||
http_request,
|
||||
request.llmwiki_knowledge_base_ids,
|
||||
)
|
||||
|
||||
try:
|
||||
if agent_dir.exists():
|
||||
@@ -860,8 +890,9 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
tool_groups=request.tool_groups,
|
||||
common_questions=request.common_questions,
|
||||
template_name=request.template_name,
|
||||
seat_skill_directive=request.seat_skill_directive,
|
||||
llmwiki_knowledge_base_ids=llmwiki_ids,
|
||||
seat_skill_directive=request.seat_skill_directive,
|
||||
job_responsibilities=request.job_responsibilities,
|
||||
llmwiki_knowledge_base_ids=llmwiki_ids,
|
||||
)
|
||||
_write_agent_soul(agent_id, request.soul)
|
||||
|
||||
@@ -942,14 +973,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
||||
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
||||
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
|
||||
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
||||
next_llmwiki_ids = (
|
||||
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
|
||||
if "llmwiki_knowledge_base_ids" in fields_set
|
||||
else existing_extras["llmwiki_knowledge_base_ids"]
|
||||
)
|
||||
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
|
||||
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
||||
next_job_responsibilities = request.job_responsibilities if "job_responsibilities" in fields_set else existing_extras["job_responsibilities"]
|
||||
next_llmwiki_ids = (
|
||||
await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids)
|
||||
if "llmwiki_knowledge_base_ids" in fields_set
|
||||
else existing_extras["llmwiki_knowledge_base_ids"]
|
||||
)
|
||||
|
||||
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "job_responsibilities", "llmwiki_knowledge_base_ids"))
|
||||
if config_dirty:
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
@@ -960,8 +992,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
tool_groups=next_tool_groups,
|
||||
common_questions=next_common_questions,
|
||||
template_name=next_template_name,
|
||||
seat_skill_directive=next_seat_skill_directive,
|
||||
llmwiki_knowledge_base_ids=next_llmwiki_ids,
|
||||
seat_skill_directive=next_seat_skill_directive,
|
||||
job_responsibilities=next_job_responsibilities,
|
||||
llmwiki_knowledge_base_ids=next_llmwiki_ids,
|
||||
)
|
||||
# Keep the listing cache in lock-step with config.yaml, but only
|
||||
# when the cached fields were actually touched — a partial update
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""CRUD API for deep-research report structures (报告结构 / 工作流配置).
|
||||
|
||||
The deep-research workbench's 高级配置 → 工作流配置 dialog renders a card per
|
||||
row; picking a card applies ``content`` to the research config's
|
||||
``custom_outline``. Global / shared — every account sees the same cards.
|
||||
|
||||
Routes (prefix ``/api/report-structures``):
|
||||
GET "" list (optional ``enabled`` / ``title`` filters)
|
||||
POST "" create (title / name / content; enabled forced True)
|
||||
GET "/{id}" get one
|
||||
PUT "/{id}" update title / name / content
|
||||
PUT "/{id}/enabled" toggle 启用/停用
|
||||
DELETE "/{id}" delete
|
||||
|
||||
The first list call seeds the two built-in outlines (通用分析结构 /
|
||||
递进式论证结构) when the table has never held a row — see
|
||||
``deerflow.persistence.report_structures.defaults``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.persistence.report_structures import ReportStructureStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/report-structures", tags=["report-structures"])
|
||||
|
||||
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReportStructureResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
name: str
|
||||
content: str
|
||||
enabled: bool = True
|
||||
created_by: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ReportStructureListResponse(BaseModel):
|
||||
structures: list[ReportStructureResponse]
|
||||
|
||||
|
||||
class ReportStructureCreateRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=256)
|
||||
name: str = Field(default="", max_length=256)
|
||||
content: str = Field(..., min_length=1, max_length=20_000)
|
||||
|
||||
|
||||
class ReportStructureUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
name: str | None = Field(default=None, max_length=256)
|
||||
content: str | None = Field(default=None, min_length=1, max_length=20_000)
|
||||
|
||||
|
||||
class ReportStructureEnabledRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_store(request: Request) -> ReportStructureStore:
|
||||
store = getattr(request.app.state, "report_structure_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Report structure store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _current_username(request: Request) -> str | None:
|
||||
"""创建用户名:邮箱(本部署的账号即邮箱),无认证时为 None。"""
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return None
|
||||
return str(user.email) or str(user.id)
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=ReportStructureListResponse)
|
||||
async def list_report_structures(
|
||||
request: Request,
|
||||
enabled: bool | None = Query(default=None, description="启用/停用筛选;缺省=全部"),
|
||||
title: str | None = Query(default=None, max_length=256, description="标题模糊筛选(大小写不敏感)"),
|
||||
) -> ReportStructureListResponse:
|
||||
store = _get_store(request)
|
||||
await store.ensure_defaults()
|
||||
rows = await store.list_structures()
|
||||
if enabled is not None:
|
||||
rows = [r for r in rows if bool(r.get("enabled", True)) is enabled]
|
||||
if title:
|
||||
needle = title.strip().lower()
|
||||
if needle:
|
||||
rows = [r for r in rows if needle in str(r.get("title", "")).lower()]
|
||||
return ReportStructureListResponse(structures=[ReportStructureResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.post("", response_model=ReportStructureResponse, status_code=201)
|
||||
async def create_report_structure(request: Request, body: ReportStructureCreateRequest) -> ReportStructureResponse:
|
||||
store = _get_store(request)
|
||||
row = await store.create_structure(
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"title": body.title.strip(),
|
||||
"name": body.name.strip(),
|
||||
"content": body.content.strip(),
|
||||
"created_by": await _current_username(request),
|
||||
}
|
||||
)
|
||||
return ReportStructureResponse(**row)
|
||||
|
||||
|
||||
@router.get("/{structure_id}", response_model=ReportStructureResponse)
|
||||
async def get_report_structure(request: Request, structure_id: str) -> ReportStructureResponse:
|
||||
store = _get_store(request)
|
||||
row = await store.get_structure(structure_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="报告结构不存在")
|
||||
return ReportStructureResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{structure_id}", response_model=ReportStructureResponse)
|
||||
async def update_report_structure(
|
||||
request: Request, structure_id: str, body: ReportStructureUpdateRequest
|
||||
) -> ReportStructureResponse:
|
||||
store = _get_store(request)
|
||||
data: dict = {}
|
||||
if body.title is not None:
|
||||
data["title"] = body.title.strip()
|
||||
if body.name is not None:
|
||||
data["name"] = body.name.strip()
|
||||
if body.content is not None:
|
||||
data["content"] = body.content.strip()
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="没有需要更新的字段")
|
||||
row = await store.update_structure(structure_id, data)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="报告结构不存在")
|
||||
return ReportStructureResponse(**row)
|
||||
|
||||
|
||||
@router.put("/{structure_id}/enabled", response_model=ReportStructureResponse)
|
||||
async def set_report_structure_enabled(
|
||||
request: Request, structure_id: str, body: ReportStructureEnabledRequest
|
||||
) -> ReportStructureResponse:
|
||||
store = _get_store(request)
|
||||
row = await store.set_enabled(structure_id, body.enabled)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="报告结构不存在")
|
||||
return ReportStructureResponse(**row)
|
||||
|
||||
|
||||
@router.delete("/{structure_id}", status_code=204)
|
||||
async def delete_report_structure(request: Request, structure_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
deleted = await store.delete_structure(structure_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="报告结构不存在")
|
||||
@@ -13,8 +13,9 @@ Routes (prefix ``/api/roundtable-chains``):
|
||||
DELETE "/{chain_id}" delete
|
||||
|
||||
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
|
||||
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
|
||||
(所有账号的全部链条),并可 get / 编辑 / 发布 / 启停 / 删除**任意** owner 的链条;非管理员
|
||||
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
|
||||
``enabled`` 默认 True:停用后管理列表仍可见,但选链弹窗 / 公共入口不再选用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -90,6 +91,8 @@ class ChainResponse(BaseModel):
|
||||
gather_first: bool = False
|
||||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||
is_public: bool = False
|
||||
# 启停状态:True = 启用(可选入研讨);False = 停用。缺省 True。
|
||||
enabled: bool = True
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
is_mine: bool = True
|
||||
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
|
||||
@@ -116,6 +119,7 @@ class ChainCreateRequest(BaseModel):
|
||||
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||
seat_mode: str | None = Field(default=None, max_length=16)
|
||||
gather_first: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ChainUpdateRequest(BaseModel):
|
||||
@@ -131,6 +135,8 @@ class ChainUpdateRequest(BaseModel):
|
||||
gather_first: bool | None = None
|
||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||
is_public: bool | None = None
|
||||
# 启停开关(True 启用 / False 停用);仅 owner / 管理员可改。omit 不改。
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -329,6 +335,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
||||
"coordinator_prompt": body.coordinator_prompt,
|
||||
"seat_mode": body.seat_mode,
|
||||
"gather_first": body.gather_first,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
)
|
||||
await _enrich_owner_names([row])
|
||||
|
||||
+19
-16
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@@ -82,7 +82,7 @@ class CommonQuestion(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for a custom agent."""
|
||||
|
||||
id: str | None = None
|
||||
@@ -108,20 +108,23 @@ class AgentConfig(BaseModel):
|
||||
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
|
||||
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
|
||||
# ``app/gateway/roundtable_seat_skills.py``。
|
||||
seat_skill_directive: bool | None = None
|
||||
# DeerFlow mapping ids this agent may offer in its new-conversation
|
||||
# knowledge selector. WeKnora ids and credentials never enter agent files.
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None
|
||||
|
||||
@field_validator("llmwiki_knowledge_base_ids")
|
||||
@classmethod
|
||||
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
if len(normalized) > 100:
|
||||
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
|
||||
return normalized
|
||||
seat_skill_directive: bool | None = None
|
||||
# 岗位职责:会商智能体配置面板展示用描述。Display/UX metadata only —
|
||||
# the runtime never reads this. None/empty = not set.
|
||||
job_responsibilities: str | None = None
|
||||
# DeerFlow mapping ids this agent may offer in its new-conversation
|
||||
# knowledge selector. WeKnora ids and credentials never enter agent files.
|
||||
llmwiki_knowledge_base_ids: list[str] | None = None
|
||||
|
||||
@field_validator("llmwiki_knowledge_base_ids")
|
||||
@classmethod
|
||||
def _normalize_llmwiki_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
|
||||
if len(normalized) > 100:
|
||||
raise ValueError("An agent can bind at most 100 LLMWiki knowledge bases")
|
||||
return normalized
|
||||
|
||||
|
||||
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"""Add enabled column to roundtable_chains.
|
||||
|
||||
Per-chain 启停 switch: True = 可在选链弹窗 / 公共入口里选用;False = 停用(管理列表
|
||||
仍可见,但不可选入研讨)。Default True → existing chains stay enabled. BOOLEAN NOT
|
||||
NULL DEFAULT 1, matching ``RoundtableChainRow.enabled``.
|
||||
|
||||
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
|
||||
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
|
||||
alembic graph consistent with the rest of the chain columns and documents the change.
|
||||
|
||||
Revision ID: 20260819_02
|
||||
Revises: 20260819_01
|
||||
Create Date: 2026-08-19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260819_02"
|
||||
down_revision = "20260819_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "enabled"):
|
||||
op.add_column(
|
||||
"roundtable_chains",
|
||||
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "enabled"):
|
||||
op.drop_column("roundtable_chains", "enabled")
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Deep-research report-structure persistence (报告结构 / 工作流配置)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
from deerflow.persistence.report_structures.memory import MemoryReportStructureStore
|
||||
from deerflow.persistence.report_structures.model import ReportStructureRow
|
||||
from deerflow.persistence.report_structures.sql import ReportStructureRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryReportStructureStore",
|
||||
"ReportStructureRepository",
|
||||
"ReportStructureRow",
|
||||
"ReportStructureStore",
|
||||
"make_report_structure_store",
|
||||
]
|
||||
|
||||
|
||||
def make_report_structure_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryReportStructureStore()
|
||||
return ReportStructureRepository(session_factory)
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
"""Abstract report-structure persistence API (报告结构 / 工作流配置).
|
||||
|
||||
Global / shared store: no ``user_id`` scoping — the deep-research 工作流配置
|
||||
dialog shows the same cards to every account. ``created_by`` is a display-only
|
||||
audit field (创建用户名). Rows are managed per-item (create / update /
|
||||
toggle-enabled / delete); ``ensure_defaults`` lazily seeds the two built-in
|
||||
outlines the first time the table is empty so a fresh deployment renders the
|
||||
same 通用分析结构 / 递进式论证结构 cards the frontend used to hardcode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportStructureStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
"""Return every stored structure, most recently modified first."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Insert ``{id, title, name, content, created_by}`` (enabled forced True)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Update any of ``title`` / ``name`` / ``content``. None when missing."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
"""Flip 启用/停用 (also bumps updated_at). None when missing."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def count(self) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
async def ensure_defaults(self) -> None:
|
||||
"""Seed the built-in outlines when the table has never held a row.
|
||||
|
||||
Concurrent first reads can race; seed ids are fixed so a losing insert
|
||||
fails on the primary key and is simply skipped.
|
||||
"""
|
||||
if await self.count() > 0:
|
||||
return
|
||||
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
|
||||
await self.create_structure(dict(row))
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
"""深度研究「报告结构」默认种子(新机器部署开箱即有的内置结构库)。
|
||||
|
||||
前两套(通用分析结构 / 递进式论证结构)逐字提取自前端
|
||||
``frontend-web/src/pages/deep-research/ReportConfigCard.tsx`` 的
|
||||
``DEFAULT_REPORT_OUTLINE`` / ``PROGRESSIVE_REPORT_OUTLINE``;其余三套为本仓库
|
||||
补充的常用研究结构(快速简报 / 事件分析 / 对象调查)。
|
||||
表为空(全新部署、从未写过数据)时 ``ensure_defaults`` 会把全部内置结构插入
|
||||
``report_structures``;已有数据的库不会追加。若调优了某套结构,请同步更新
|
||||
本文件,否则新机器补建出来的还是旧版。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
GENERAL_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、提出本议题的背景、研究范围及资料边界;简要交代全文的分析路径,不单独拆分小节。
|
||||
|
||||
# 一、研究对象与事实基础
|
||||
## 1.1 概念、范围与背景
|
||||
界定研究对象、关键概念、时间和空间范围;说明读者理解问题所需的背景。
|
||||
## 1.2 已有事实与证据
|
||||
按时间或逻辑顺序梳理已知事实、数据、案例或材料发现;优先使用可核验的来源。
|
||||
## 1.3 当前状态与主要特征
|
||||
归纳研究对象目前呈现的状态、关键变化、共性与差异;没有数据支撑时明确说明。
|
||||
|
||||
# 二、问题形成与作用机制
|
||||
## 2.1 直接原因与触发条件
|
||||
分析近期事件、条件变化或关键节点如何推动问题出现或演变。
|
||||
## 2.2 深层影响因素
|
||||
从制度、技术、资源、行为、环境或历史条件中选择适用维度,分析长期影响。
|
||||
## 2.3 因果关系与相互作用
|
||||
说明不同因素如何共同作用;区分来源已证实的事实、分析判断和仍待验证的推断。
|
||||
|
||||
# 三、影响、问题与不同观点
|
||||
## 3.1 对研究对象的直接影响
|
||||
从结果、效率、质量、风险、成本、体验或其他适用维度,分析当前影响。
|
||||
## 3.2 对相关主体与外部环境的影响
|
||||
说明影响如何传导到相关群体、组织、地区、系统或后续事项;不适用的维度可省略。
|
||||
## 3.3 主要争议与解释差异
|
||||
并列材料支持的不同观点、解释或结论,并说明各自的证据基础与局限。
|
||||
|
||||
# 四、比较、趋势与不确定性
|
||||
## 4.1 可比案例或横向比较
|
||||
在材料允许时选择历史案例、不同地区、不同方案或不同阶段进行比较,说明异同与启示。
|
||||
## 4.2 发展趋势与关键变量
|
||||
识别后续可能变化、决定性变量、观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.3 风险情景与不确定性
|
||||
说明不同情景的触发条件、可能影响和证据不足之处;对没有依据的推测明确标注。
|
||||
|
||||
# 五、结论、建议与后续关注
|
||||
## 5.1 核心结论
|
||||
用编号或分点方式收束全文;每条结论都应能回溯到正文中的事实和分析。
|
||||
## 5.2 可行建议或应对方向
|
||||
仅在研究材料支持时提出建议;说明适用对象、前提条件、优先级和预期作用。
|
||||
## 5.3 资料局限与后续关注
|
||||
列出证据缺口、数据口径差异、尚不能下结论的问题,以及值得持续跟踪的事项。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:引言保持简洁;每个二级小节按材料充分程度写 2–5 段完整分析,每段围绕一个明确观点展开,采用“事实/证据 → 分析 → 影响或小结”的顺序。章节可根据研究主题合并、增删或调整,但不要为了套模板而编造不适用的主体、数据、案例或引用。"""
|
||||
|
||||
PROGRESSIVE_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明研究对象、核心问题、研究范围和资料边界;简要说明本文将按“事实基线 → 成因机制 → 影响判断 → 应对方向 → 结论”的递进逻辑展开。
|
||||
|
||||
# 一、事实基线:问题是什么
|
||||
## 1.1 概念界定与研究范围
|
||||
明确关键概念、时间/对象边界和判断口径,避免后续讨论混用概念。
|
||||
## 1.2 关键事实、数据与证据
|
||||
按时间或逻辑顺序列出已经发生的事实、可核验数据、案例和来源。
|
||||
## 1.3 基线判断与待解问题
|
||||
基于前两节归纳当前状态、主要矛盾和需要解释的核心问题。
|
||||
|
||||
> 本章产出:后续分析必须以本章已经确认的事实和问题为基础,不得在后文补写没有来源支撑的新事实。
|
||||
|
||||
# 二、形成机制:为什么会这样
|
||||
## 2.1 直接原因与触发条件
|
||||
从第一章识别的问题出发,分析近期事件、条件变化或关键节点如何导致当前结果。
|
||||
## 2.2 深层条件与关键约束
|
||||
结合第一章的证据,说明制度、技术、资源、行为、环境或历史条件等长期影响。
|
||||
## 2.3 因果链条与关键变量
|
||||
把前两节串成清晰的“条件 → 机制 → 结果”链条,标出决定性变量及仍待验证的环节。
|
||||
|
||||
> 本章产出:第三章只能沿用本章已说明的机制评估影响,不能把相关性直接写成因果关系。
|
||||
|
||||
# 三、结果与影响:会带来什么
|
||||
## 3.1 直接结果与短期影响
|
||||
依据第二章的因果链条,分析对研究对象最直接、最先出现的影响。
|
||||
## 3.2 传导效应与中长期影响
|
||||
继续说明影响如何传导至相关主体、外部环境或后续事项;不适用的维度可省略。
|
||||
## 3.3 权衡、分歧与边界
|
||||
比较不同影响之间的取舍、材料支持的不同观点,以及结论适用的边界。
|
||||
|
||||
> 本章产出:第四章的趋势判断、情景和建议应以本章已评估的影响、风险和权衡为依据。
|
||||
|
||||
# 四、趋势与应对:接下来怎么办
|
||||
## 4.1 未来走势与观察指标
|
||||
从第三章的影响出发,识别可能变化、关键观察指标和潜在转折点;不把预测写成既成事实。
|
||||
## 4.2 可选方案与适用条件
|
||||
针对第三章发现的问题和风险,提出可行的应对方向、前提条件与优先级。
|
||||
## 4.3 风险情景与验证重点
|
||||
说明不同情景的触发条件、可能后果,以及下一步需要补充验证的数据或材料。
|
||||
|
||||
> 本章产出:第五章只综合前四章已经建立的事实、机制、影响和建议,不新增独立论点。
|
||||
|
||||
# 五、综合结论与后续关注
|
||||
## 5.1 核心结论
|
||||
按“事实基线 → 原因机制 → 主要影响 → 应对方向”逐项收束,每条结论都可回溯到前文证据。
|
||||
## 5.2 局限与后续研究
|
||||
列出资料缺口、数据口径差异、尚不能下结论的问题和后续跟踪清单。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。"""
|
||||
|
||||
BRIEF_OUTLINE = """# 报告标题
|
||||
|
||||
# 核心结论
|
||||
用 3–5 条要点直接给出最重要的结论与判断,每条一两句话并可回溯到后文依据;先说结论,再列支撑。
|
||||
|
||||
# 关键事实
|
||||
## 事实清单
|
||||
按重要性列出已核验的关键事实、数据与出处;不确定的信息单独注明置信度。
|
||||
## 时间与背景
|
||||
用最短的篇幅交代理解结论所需的事件顺序与背景,不展开细节。
|
||||
|
||||
# 风险与不确定性
|
||||
列出可能推翻或削弱上述结论的因素、证据缺口和正在变化中的变量。
|
||||
|
||||
# 建议行动
|
||||
按优先级给出可执行的建议或下一步关注点,说明适用前提与预期作用;材料不足时明确说明证据尚不支持。
|
||||
|
||||
# 参考资料
|
||||
仅列出本简报实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:全文控制在较短篇幅(建议 800–1500 字),信息密度优先;不写套话与背景铺陈,所有论断要么有来源支撑,要么明确标注为分析判断。"""
|
||||
|
||||
|
||||
EVENT_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
用 1–2 段说明事件的性质、发生时间地点、当前状态与本报告要回答的核心问题。
|
||||
|
||||
# 一、事件概述
|
||||
## 1.1 事件是什么
|
||||
客观描述发生了什么:主体、行为、规模、直接结果;只写已核验事实。
|
||||
## 1.2 各方说法
|
||||
并列当事方、官方与媒体的不同表述,注明各自的信息来源与立场。
|
||||
|
||||
# 二、时间线还原
|
||||
## 2.1 起因与前情
|
||||
梳理事件发生的背景、前置条件与诱发节点。
|
||||
## 2.2 关键节点
|
||||
按时间顺序还原事件演进的关键节点,每个节点注明时间与来源;存在争议的时间点单独标注。
|
||||
## 2.3 当前状态
|
||||
说明事件目前的进展、处置情况与未决事项。
|
||||
|
||||
# 三、原因与责任分析
|
||||
## 3.1 直接原因
|
||||
分析触发事件的直接因素与机制。
|
||||
## 3.2 深层原因
|
||||
从制度、管理、技术、环境等维度分析更深层的促成条件。
|
||||
## 3.3 责任与归因
|
||||
基于事实分析各方责任,区分已认定的结论与仍在调查的部分。
|
||||
|
||||
# 四、影响与反应
|
||||
## 4.1 直接影响
|
||||
评估事件对当事方与相关群体已造成的影响。
|
||||
## 4.2 各方反应与后续动作
|
||||
梳理官方处置、当事方回应、舆论反应与衍生事件。
|
||||
## 4.3 潜在延伸影响
|
||||
分析可能的中长期影响与传导路径,注明判断依据。
|
||||
|
||||
# 五、结论与后续关注
|
||||
## 5.1 核心结论
|
||||
收束事件性质、原因与影响的核心判断。
|
||||
## 5.2 待验证问题与观察点
|
||||
列出尚未证实的关键问题与值得持续跟踪的信号。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:严格区分“已证实事实—各方声称—分析推断”三个层次并分别标注;时间线每个节点须有来源;对争议点呈现多方说法,不单方采信。"""
|
||||
|
||||
PROFILE_OUTLINE = """# 报告标题
|
||||
|
||||
# 引言
|
||||
说明调查对象的界定范围(个人、组织或产品)、研究目的与资料边界(公开信息的时间范围与覆盖面)。
|
||||
|
||||
# 一、基本情况
|
||||
## 1.1 基本信息与背景
|
||||
梳理对象的公认基本信息与来历,注明信息时效。
|
||||
## 1.2 发展沿革
|
||||
按时间梳理关键成长节点、转折与现状。
|
||||
|
||||
# 二、能力与表现
|
||||
## 2.1 核心能力或竞争力
|
||||
分析对象的核心能力、资源禀赋或产品力,给出可观察的证据。
|
||||
## 2.2 代表性成果与表现
|
||||
列举代表性成果、业绩或事件表现,并评估其含金量与可持续性。
|
||||
## 2.3 短板与争议
|
||||
并列材料中的短板、批评与争议点,说明各方依据。
|
||||
|
||||
# 三、关系网络与生态
|
||||
## 3.1 关联方与合作关系
|
||||
梳理重要的合作关系、关联方与利益结构。
|
||||
## 3.2 立场与倾向
|
||||
基于言行记录分析对象的立场、风格与决策倾向。
|
||||
|
||||
# 四、风险评估
|
||||
## 4.1 已知风险
|
||||
汇总已暴露的风险点、负面记录与可靠性问题。
|
||||
## 4.2 潜在风险与信息缺口
|
||||
指出资料不足导致的盲区与需要进一步核实的问题。
|
||||
|
||||
# 五、综合评估
|
||||
## 5.1 画像结论
|
||||
用几句话给出对象的综合画像与可信度评估。
|
||||
## 5.2 交往或合作建议
|
||||
给出与对象打交道的注意事项与建议,说明前提。
|
||||
|
||||
# 参考资料
|
||||
仅列出本报告实际使用的来源,并与正文引用保持一致。
|
||||
|
||||
> 写作要求:只依据公开可核验信息,猜测与推断必须明确标注;负面信息须注明来源与时间,区分“事实—评价—传闻”;信息不足时如实说明,不为了完整而编造。"""
|
||||
|
||||
|
||||
# 固定 id:并发首查时重复插入会撞主键而被吞掉,不会产生重复行。
|
||||
DEFAULT_REPORT_STRUCTURE_ROWS: list[dict] = [
|
||||
{
|
||||
"id": "seed-report-outline-general",
|
||||
"title": "通用分析结构",
|
||||
"name": "通用分析结构",
|
||||
"content": GENERAL_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-progressive",
|
||||
"title": "递进式论证结构",
|
||||
"name": "递进式论证结构",
|
||||
"content": PROGRESSIVE_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-brief",
|
||||
"title": "快速简报结构",
|
||||
"name": "快速简报结构",
|
||||
"content": BRIEF_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-event",
|
||||
"title": "事件分析结构",
|
||||
"name": "事件分析结构",
|
||||
"content": EVENT_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
{
|
||||
"id": "seed-report-outline-profile",
|
||||
"title": "对象调查结构",
|
||||
"name": "对象调查结构",
|
||||
"content": PROFILE_OUTLINE,
|
||||
"created_by": "系统内置",
|
||||
},
|
||||
]
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""In-memory report-structure store (auth-less tests / no-DB fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return sorted(rows, key=lambda r: (r.get("updated_at") or "", r.get("created_at") or ""), reverse=True)
|
||||
|
||||
|
||||
class MemoryReportStructureStore(ReportStructureStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
return _sort_rows(list(self._rows.values()))
|
||||
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
row = {
|
||||
"id": str(data["id"]),
|
||||
"title": str(data.get("title") or ""),
|
||||
"name": str(data.get("name") or ""),
|
||||
"content": str(data.get("content") or ""),
|
||||
"enabled": True,
|
||||
"created_by": data.get("created_by"),
|
||||
"created_at": _now_iso(),
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("title", "name", "content"):
|
||||
if key in data:
|
||||
row[key] = str(data[key])
|
||||
row["updated_at"] = _now_iso()
|
||||
return dict(row)
|
||||
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
row = self._rows.get(structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
row["enabled"] = bool(enabled)
|
||||
row["updated_at"] = _now_iso()
|
||||
return dict(row)
|
||||
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
return self._rows.pop(structure_id, None) is not None
|
||||
|
||||
async def count(self) -> int:
|
||||
return len(self._rows)
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""ORM model for deep-research report structures (报告结构 / 工作流配置).
|
||||
|
||||
Global / shared — every account sees the same structure cards in the deep
|
||||
research workbench's 工作流配置 dialog. Each row is one reusable report
|
||||
structure the user can pick to fill ``custom_outline``:
|
||||
|
||||
- ``title`` — 标题, the card's primary label and the list's 标题筛选 target.
|
||||
- ``name`` — 名称, a secondary display label.
|
||||
- ``content`` — 接口内容, the Markdown outline text applied to the research
|
||||
config's ``custom_outline`` when the card is selected.
|
||||
- ``enabled`` — 启用/停用; creation always defaults to True (the caller cannot
|
||||
choose it) and a dedicated toggle flips it later.
|
||||
- ``created_by`` — 创建用户名 (audit-only display field).
|
||||
|
||||
Fresh deployments get the two built-in outlines (通用分析结构 / 递进式论证结构)
|
||||
seeded by ``ensure_defaults`` — see ``defaults.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class ReportStructureRow(Base):
|
||||
__tablename__ = "report_structures"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(256), default="", nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# ``server_default`` 必须显式声明:否则在已存在该表但缺列的旧库上,启动期
|
||||
# ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列(见
|
||||
# engine.py),该列永远补不上 → 查询报 Unknown column → 500。
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="1")
|
||||
created_by: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""SQLAlchemy-backed report-structure store (global / shared)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.report_structures.base import ReportStructureStore
|
||||
from deerflow.persistence.report_structures.model import ReportStructureRow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_dict(row: ReportStructureRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
for key in ("created_at", "updated_at"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, datetime):
|
||||
data[key] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
class ReportStructureRepository(ReportStructureStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_structures(self) -> list[dict[str, Any]]:
|
||||
stmt = select(ReportStructureRow).order_by(
|
||||
ReportStructureRow.updated_at.desc(), ReportStructureRow.created_at.asc()
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_structure(self, structure_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def create_structure(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = ReportStructureRow(
|
||||
id=str(data["id"]),
|
||||
title=str(data.get("title") or ""),
|
||||
name=str(data.get("name") or ""),
|
||||
content=str(data.get("content") or ""),
|
||||
# 创建永远默认启用(前端不提供该项填写)。
|
||||
enabled=True,
|
||||
created_by=data.get("created_by"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_structure(self, structure_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("title", "name", "content"):
|
||||
if key in data:
|
||||
setattr(row, key, str(data[key]))
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def set_enabled(self, structure_id: str, enabled: bool) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(ReportStructureRow, structure_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.enabled = bool(enabled)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_structure(self, structure_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(delete(ReportStructureRow).where(ReportStructureRow.id == structure_id))
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self._sf() as session:
|
||||
return int(await session.scalar(select(func.count()).select_from(ReportStructureRow)) or 0)
|
||||
|
||||
async def ensure_defaults(self) -> None:
|
||||
"""Seed when empty; a concurrent first-read loses on the fixed PK silently."""
|
||||
if await self.count() > 0:
|
||||
return
|
||||
from deerflow.persistence.report_structures.defaults import DEFAULT_REPORT_STRUCTURE_ROWS
|
||||
|
||||
for row in DEFAULT_REPORT_STRUCTURE_ROWS:
|
||||
try:
|
||||
await self.create_structure(dict(row))
|
||||
except IntegrityError:
|
||||
logger.debug("report-structure default '%s' already inserted by a concurrent request", row["id"])
|
||||
+6
@@ -63,6 +63,12 @@ class RoundtableChainRow(Base):
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0", index=True
|
||||
)
|
||||
# 启停开关:True = 启用(可在选链弹窗 / 公共入口里选用);False = 停用(管理列表仍可见,
|
||||
# 但不可选入研讨)。默认启用,存量链条补列后也为 True。``server_default`` 必须显式声明,
|
||||
# 否则既有表上启动期 ``_ensure_orm_columns_sync`` 会跳过「NOT NULL 且无 server_default」的列。
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
+6
@@ -62,6 +62,8 @@ class RoundtableChainRepository:
|
||||
"gather_first": bool(getattr(row, "gather_first", False)),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
# 启停状态:缺省 True(存量行 / 补列前视为启用)。
|
||||
"enabled": bool(getattr(row, "enabled", True)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
|
||||
"owner_id": row.user_id,
|
||||
@@ -122,6 +124,7 @@ class RoundtableChainRepository:
|
||||
seat_mode=(data.get("seat_mode") or None),
|
||||
gather_first=bool(data.get("gather_first") or False),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
enabled=True if data.get("enabled") is None else bool(data.get("enabled")),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -145,6 +148,7 @@ class RoundtableChainRepository:
|
||||
seat_mode=_UNSET,
|
||||
gather_first=_UNSET,
|
||||
is_public=_UNSET,
|
||||
enabled=_UNSET,
|
||||
is_admin: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -170,6 +174,8 @@ class RoundtableChainRepository:
|
||||
row.gather_first = bool(gather_first)
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
if enabled is not _UNSET and enabled is not None:
|
||||
row.enabled = bool(enabled)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
@@ -94,6 +94,11 @@ CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL
|
||||
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
|
||||
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
|
||||
-- enabled: 业务链条启停开关,对应 migration 20260819_02。True=启用(可选入研讨);
|
||||
-- False=停用(管理列表仍可见)。默认启用,存量行补列后也为 True。
|
||||
-- ORM: RoundtableChainRow.enabled = Boolean, NOT NULL, server_default '1'。
|
||||
CALL _add_column_if_missing('roundtable_chains', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1');
|
||||
|
||||
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
|
||||
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
|
||||
-- 以下是给【已存在的表】补的列:
|
||||
@@ -212,5 +217,6 @@ SELECT table_name, column_name
|
||||
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
|
||||
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
|
||||
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param'))
|
||||
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type'))
|
||||
OR (table_name = 'compact_menu_overrides' AND column_name = 'menu_type')
|
||||
OR (table_name = 'roundtable_chains' AND column_name = 'enabled'))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -26,7 +26,7 @@ from deerflow.persistence.tags.memory import MemoryTagStore
|
||||
from deerflow.services.position_sync import PositionSyncService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path, monkeypatch):
|
||||
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
@@ -119,20 +119,20 @@ async def _default_positions() -> MemoryPositionStore:
|
||||
}
|
||||
)
|
||||
return positions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_store_allows_duplicate_chinese_names_and_filters_visibility() -> None:
|
||||
store = MemoryAgentStore()
|
||||
chinese_name = "\u4e2d\u6587\u52a9\u624b"
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
|
||||
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
|
||||
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
|
||||
|
||||
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
|
||||
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": chinese_name, "description": "built in"})
|
||||
await store.create({"id": "private-a", "name": chinese_name, "description": "private", "user_id": "user-a"})
|
||||
await store.create({"id": "published-b", "name": chinese_name, "description": "published", "user_id": "user-b", "published": True})
|
||||
|
||||
user_a_ids = {agent["id"] for agent in await store.list_visible("user-a")}
|
||||
assert user_a_ids == {"builtin-agent", "private-a", "published-b"}
|
||||
|
||||
user_c_ids = {agent["id"] for agent in await store.list_visible("user-c")}
|
||||
assert user_c_ids == {"builtin-agent", "published-b"}
|
||||
|
||||
@@ -271,254 +271,297 @@ async def test_manual_favorite_upgrades_existing_auto_builtin_favorite(sql_sessi
|
||||
async def test_agent_store_only_owner_can_update_or_delete() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
|
||||
assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
|
||||
|
||||
updated = await store.update("private-a", "user-a", {"name": "Renamed"})
|
||||
assert updated is not None
|
||||
assert updated["name"] == "Renamed"
|
||||
|
||||
assert await store.delete("private-a", "user-b") is False
|
||||
assert await store.delete("private-a", "user-a") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_builtin_only_targets_builtins() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# delete_builtin refuses to touch a user-owned agent
|
||||
assert await store.delete_builtin("owned-agent") is False
|
||||
assert await store.get_any("owned-agent") is not None
|
||||
|
||||
# delete_builtin succeeds on a built-in
|
||||
assert await store.delete_builtin("builtin-agent") is True
|
||||
assert await store.get_any("builtin-agent") is None
|
||||
|
||||
# And no-ops on a missing id
|
||||
assert await store.delete_builtin("does-not-exist") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_builtin_only_targets_builtins() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# update_builtin succeeds on a built-in agent
|
||||
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
|
||||
assert updated is not None
|
||||
assert updated["name"] == "Renamed by admin"
|
||||
|
||||
# update_builtin refuses to touch a user-owned agent (returns None)
|
||||
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
|
||||
|
||||
# And on a missing agent
|
||||
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
|
||||
"""Route helper: admin gets edit access to any agent; non-admin still 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.agents import _get_editable_or_raise
|
||||
|
||||
store = MemoryAgentStore()
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# Owner edits their own agent → returns ('owner', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "owned-agent", "user-a", is_admin=False
|
||||
)
|
||||
assert mode == "owner" and record["id"] == "owned-agent"
|
||||
|
||||
# Admin edits a built-in agent → returns ('admin', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "builtin-agent", "user-a", is_admin=True
|
||||
)
|
||||
assert mode == "admin" and record["id"] == "builtin-agent"
|
||||
|
||||
# Non-admin tries to edit a built-in → 403
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Admin edits someone else's custom agent → returns ('admin', record).
|
||||
# Behavior changed: admins manage the full agent catalog (built-in + any
|
||||
# user's agents), not just built-ins. Non-admins are still owner-only.
|
||||
await store.create(
|
||||
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
|
||||
)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "published-other", "user-a", is_admin=True
|
||||
)
|
||||
assert mode == "admin" and record["id"] == "published-other"
|
||||
|
||||
|
||||
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
|
||||
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
|
||||
|
||||
assert config["configurable"]["thread_id"] == "thread-1"
|
||||
assert config["configurable"]["agent_id"] == "agent-123"
|
||||
assert "agent_name" not in config["configurable"]
|
||||
|
||||
|
||||
def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
|
||||
agent_id = "agent-001"
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
name="中文助手",
|
||||
description="desc",
|
||||
skills=["s1", "s2"],
|
||||
model="gpt-x",
|
||||
tool_groups=["tg-a"],
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["id"] == agent_id
|
||||
assert raw["name"] == "中文助手"
|
||||
assert raw["description"] == "desc"
|
||||
assert raw["skills"] == ["s1", "s2"]
|
||||
assert raw["model"] == "gpt-x"
|
||||
assert raw["tool_groups"] == ["tg-a"]
|
||||
|
||||
|
||||
def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
|
||||
agent_id = "agent-002"
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
name="agent-002",
|
||||
description="",
|
||||
skills=["s1"],
|
||||
model=None,
|
||||
tool_groups=None,
|
||||
)
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
|
||||
|
||||
|
||||
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
|
||||
extras = _load_config_extras("does-not-exist")
|
||||
assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
|
||||
|
||||
|
||||
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-1"
|
||||
# Structured {title, prompt} questions round-trip as clean dict mappings.
|
||||
questions = [
|
||||
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
|
||||
{"title": "上传文件", "prompt": "如何上传文件?"},
|
||||
]
|
||||
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["common_questions"] == questions
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert [q.model_dump() for q in extras["common_questions"]] == questions
|
||||
|
||||
|
||||
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
|
||||
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
|
||||
agent_id = "agent-cq-legacy"
|
||||
_write_agent_config(
|
||||
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["common_questions"] == [
|
||||
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
|
||||
]
|
||||
|
||||
|
||||
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-2"
|
||||
_write_agent_config(agent_id, name="x", description="")
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert "common_questions" not in raw
|
||||
assert _load_config_extras(agent_id)["common_questions"] is None
|
||||
|
||||
|
||||
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-3"
|
||||
_write_agent_config(agent_id, name="x", description="", common_questions=[])
|
||||
|
||||
assert _load_config_extras(agent_id)["common_questions"] == []
|
||||
|
||||
|
||||
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
|
||||
agent_id = "agent-003"
|
||||
_write_agent_config(agent_id, name="x", description="", skills=[])
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert extras["skills"] == []
|
||||
|
||||
|
||||
def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
|
||||
agent_id = "agent-004"
|
||||
_write_agent_config(agent_id, name="x", description="")
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
|
||||
assert "skills" not in raw
|
||||
assert "model" not in raw
|
||||
assert "tool_groups" not in raw
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_listing_cache_mirrors_config_values() -> None:
|
||||
store = MemoryAgentStore()
|
||||
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
|
||||
extras = await store.get_extras_for(["a1"])
|
||||
assert extras["a1"]["skills"] == ["s1", "s2"]
|
||||
assert extras["a1"]["tool_groups"] == ["g1"]
|
||||
assert extras["a1"]["model"] == "m1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
|
||||
store = MemoryAgentStore()
|
||||
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
|
||||
# On a second run with identical config, no re-write should occur.
|
||||
calls = {"replace": 0, "set": 0}
|
||||
orig_replace = store.replace_skills
|
||||
orig_set = store.set_extras
|
||||
|
||||
async def _replace(agent_id, skills):
|
||||
calls["replace"] += 1
|
||||
await orig_replace(agent_id, skills)
|
||||
|
||||
async def _set(agent_id, *, tool_groups, model):
|
||||
calls["set"] += 1
|
||||
await orig_set(agent_id, tool_groups=tool_groups, model=model)
|
||||
|
||||
monkeypatch.setattr(store, "replace_skills", _replace)
|
||||
monkeypatch.setattr(store, "set_extras", _set)
|
||||
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
assert calls == {"replace": 0, "set": 0}
|
||||
|
||||
# Changing the config triggers a re-write.
|
||||
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
|
||||
await _backfill_listing_cache(store, [("a1", cfg2)])
|
||||
assert calls["replace"] == 1
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "private-a", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
assert await store.update("builtin-agent", "user-a", {"name": "Nope"}) is None
|
||||
assert await store.update("private-a", "user-b", {"name": "Nope"}) is None
|
||||
|
||||
updated = await store.update("private-a", "user-a", {"name": "Renamed"})
|
||||
assert updated is not None
|
||||
assert updated["name"] == "Renamed"
|
||||
|
||||
assert await store.delete("private-a", "user-b") is False
|
||||
assert await store.delete("private-a", "user-a") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_builtin_only_targets_builtins() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# delete_builtin refuses to touch a user-owned agent
|
||||
assert await store.delete_builtin("owned-agent") is False
|
||||
assert await store.get_any("owned-agent") is not None
|
||||
|
||||
# delete_builtin succeeds on a built-in
|
||||
assert await store.delete_builtin("builtin-agent") is True
|
||||
assert await store.get_any("builtin-agent") is None
|
||||
|
||||
# And no-ops on a missing id
|
||||
assert await store.delete_builtin("does-not-exist") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_builtin_only_targets_builtins() -> None:
|
||||
store = MemoryAgentStore()
|
||||
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# update_builtin succeeds on a built-in agent
|
||||
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
|
||||
assert updated is not None
|
||||
assert updated["name"] == "Renamed by admin"
|
||||
|
||||
# update_builtin refuses to touch a user-owned agent (returns None)
|
||||
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
|
||||
|
||||
# And on a missing agent
|
||||
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
|
||||
"""Route helper: admin gets edit access to any agent; non-admin still 403."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.agents import _get_editable_or_raise
|
||||
|
||||
store = MemoryAgentStore()
|
||||
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
|
||||
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
|
||||
|
||||
# Owner edits their own agent → returns ('owner', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "owned-agent", "user-a", is_admin=False
|
||||
)
|
||||
assert mode == "owner" and record["id"] == "owned-agent"
|
||||
|
||||
# Admin edits a built-in agent → returns ('admin', record)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "builtin-agent", "user-a", is_admin=True
|
||||
)
|
||||
assert mode == "admin" and record["id"] == "builtin-agent"
|
||||
|
||||
# Non-admin tries to edit a built-in → 403
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Admin edits someone else's custom agent → returns ('admin', record).
|
||||
# Behavior changed: admins manage the full agent catalog (built-in + any
|
||||
# user's agents), not just built-ins. Non-admins are still owner-only.
|
||||
await store.create(
|
||||
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
|
||||
)
|
||||
record, mode = await _get_editable_or_raise(
|
||||
store, "published-other", "user-a", is_admin=True
|
||||
)
|
||||
assert mode == "admin" and record["id"] == "published-other"
|
||||
|
||||
|
||||
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
|
||||
config = build_run_config("thread-1", None, None, assistant_id="agent-123")
|
||||
|
||||
assert config["configurable"]["thread_id"] == "thread-1"
|
||||
assert config["configurable"]["agent_id"] == "agent-123"
|
||||
assert "agent_name" not in config["configurable"]
|
||||
|
||||
|
||||
def test_write_agent_config_persists_skills_into_config_yaml(isolated_paths) -> None:
|
||||
agent_id = "agent-001"
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
name="中文助手",
|
||||
description="desc",
|
||||
skills=["s1", "s2"],
|
||||
model="gpt-x",
|
||||
tool_groups=["tg-a"],
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
|
||||
assert raw["id"] == agent_id
|
||||
assert raw["name"] == "中文助手"
|
||||
assert raw["description"] == "desc"
|
||||
assert raw["skills"] == ["s1", "s2"]
|
||||
assert raw["model"] == "gpt-x"
|
||||
assert raw["tool_groups"] == ["tg-a"]
|
||||
|
||||
|
||||
def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> None:
|
||||
agent_id = "agent-002"
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
name="agent-002",
|
||||
description="",
|
||||
skills=["s1"],
|
||||
model=None,
|
||||
tool_groups=None,
|
||||
)
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert extras == {
|
||||
"skills": ["s1"],
|
||||
"model": None,
|
||||
"tool_groups": None,
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
|
||||
|
||||
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
|
||||
extras = _load_config_extras("does-not-exist")
|
||||
assert extras == {
|
||||
"skills": None,
|
||||
"model": None,
|
||||
"tool_groups": None,
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
|
||||
|
||||
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-1"
|
||||
# Structured {title, prompt} questions round-trip as clean dict mappings.
|
||||
questions = [
|
||||
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
|
||||
{"title": "上传文件", "prompt": "如何上传文件?"},
|
||||
]
|
||||
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["common_questions"] == questions
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert [q.model_dump() for q in extras["common_questions"]] == questions
|
||||
|
||||
|
||||
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
|
||||
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
|
||||
agent_id = "agent-cq-legacy"
|
||||
_write_agent_config(
|
||||
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["common_questions"] == [
|
||||
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
|
||||
]
|
||||
|
||||
|
||||
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-2"
|
||||
_write_agent_config(agent_id, name="x", description="")
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert "common_questions" not in raw
|
||||
assert _load_config_extras(agent_id)["common_questions"] is None
|
||||
|
||||
|
||||
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-3"
|
||||
_write_agent_config(agent_id, name="x", description="", common_questions=[])
|
||||
|
||||
assert _load_config_extras(agent_id)["common_questions"] == []
|
||||
|
||||
|
||||
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None:
|
||||
agent_id = "agent-003"
|
||||
_write_agent_config(agent_id, name="x", description="", skills=[])
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert extras["skills"] == []
|
||||
|
||||
|
||||
def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
|
||||
agent_id = "agent-004"
|
||||
_write_agent_config(agent_id, name="x", description="")
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
|
||||
assert "skills" not in raw
|
||||
assert "model" not in raw
|
||||
assert "tool_groups" not in raw
|
||||
|
||||
|
||||
def test_write_agent_config_persists_job_responsibilities(isolated_paths) -> None:
|
||||
agent_id = "agent-job-1"
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
name="助手",
|
||||
description="",
|
||||
job_responsibilities=" 负责意图识别与业务链确认 ",
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["job_responsibilities"] == "负责意图识别与业务链确认"
|
||||
assert _load_config_extras(agent_id)["job_responsibilities"] == "负责意图识别与业务链确认"
|
||||
|
||||
|
||||
def test_write_agent_config_omits_blank_job_responsibilities(isolated_paths) -> None:
|
||||
agent_id = "agent-job-2"
|
||||
_write_agent_config(agent_id, name="助手", description="", job_responsibilities=" ")
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert "job_responsibilities" not in raw
|
||||
assert _load_config_extras(agent_id)["job_responsibilities"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_listing_cache_mirrors_config_values() -> None:
|
||||
store = MemoryAgentStore()
|
||||
cfg = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=["g1"], model="m1")
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
|
||||
extras = await store.get_extras_for(["a1"])
|
||||
assert extras["a1"]["skills"] == ["s1", "s2"]
|
||||
assert extras["a1"]["tool_groups"] == ["g1"]
|
||||
assert extras["a1"]["model"] == "m1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_listing_cache_is_idempotent_and_skips_unchanged(monkeypatch) -> None:
|
||||
store = MemoryAgentStore()
|
||||
cfg = AgentConfig(id="a1", name="Agent", skills=["s1"], tool_groups=None, model=None)
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
|
||||
# On a second run with identical config, no re-write should occur.
|
||||
calls = {"replace": 0, "set": 0}
|
||||
orig_replace = store.replace_skills
|
||||
orig_set = store.set_extras
|
||||
|
||||
async def _replace(agent_id, skills):
|
||||
calls["replace"] += 1
|
||||
await orig_replace(agent_id, skills)
|
||||
|
||||
async def _set(agent_id, *, tool_groups, model):
|
||||
calls["set"] += 1
|
||||
await orig_set(agent_id, tool_groups=tool_groups, model=model)
|
||||
|
||||
monkeypatch.setattr(store, "replace_skills", _replace)
|
||||
monkeypatch.setattr(store, "set_extras", _set)
|
||||
|
||||
await _backfill_listing_cache(store, [("a1", cfg)])
|
||||
assert calls == {"replace": 0, "set": 0}
|
||||
|
||||
# Changing the config triggers a re-write.
|
||||
cfg2 = AgentConfig(id="a1", name="Agent", skills=["s1", "s2"], tool_groups=None, model=None)
|
||||
await _backfill_listing_cache(store, [("a1", cfg2)])
|
||||
assert calls["replace"] == 1
|
||||
Reference in new issue
Block a user