Merge branch 'master-nj' into 'master'
fix: 单轮 RAG 步骤条思考内容支持可点击参考序号 See merge request knowledge-base-bj/deerflow-code!27
This commit is contained in:
commit
7c34e18ad6
41 files changed
+3905
-408
No files matched your search
+5
-3
@@ -98,9 +98,11 @@ offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
deploy
|
||||
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
|
||||
# Independent enterprise-research persistence, HTTP and race regressions.
|
||||
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Cloned third-party repos (local only)
|
||||
|
||||
@@ -84,7 +84,6 @@ interface AgentDraft {
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
jobResponsibilities: string;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -99,7 +98,6 @@ 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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,6 +151,7 @@ export function AgentDetailPanel({
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
embedded,
|
||||
hideStatusBadges = false,
|
||||
@@ -173,6 +172,11 @@ export function AgentDetailPanel({
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
|
||||
*/
|
||||
jobResponsibilities?: string | null;
|
||||
/** Overrides the standard panel heading for an embedded consumer. */
|
||||
panelTitle?: string;
|
||||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||||
@@ -341,8 +345,6 @@ 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 ?? [])
|
||||
@@ -725,37 +727,21 @@ 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>
|
||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||
{jobResponsibilities !== undefined && (
|
||||
<Section
|
||||
icon={<BriefcaseBusinessIcon className="size-3.5" />}
|
||||
title="岗位职责"
|
||||
>
|
||||
{jobResponsibilities?.trim() ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{desensitize(jobResponsibilities)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SquareTerminalIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type AnchorHTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
@@ -58,6 +58,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
@@ -71,20 +72,24 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
messages,
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
@@ -121,11 +126,29 @@ export function MessageGroup({
|
||||
}
|
||||
}, [lastToolCallStep, steps]);
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const [selectedCitation, setSelectedCitation] =
|
||||
useState<RuntimeDisplaySource | null>(null);
|
||||
const handleCitationClick = useCallback(
|
||||
(referenceId: string) => {
|
||||
const referenceIndex = Number(referenceId);
|
||||
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
||||
const source =
|
||||
displaySources.find((item) => item.index === referenceIndex) ??
|
||||
displaySources[referenceIndex - 1];
|
||||
if (source) setSelectedCitation(source);
|
||||
},
|
||||
[displaySources],
|
||||
);
|
||||
const reasoningId =
|
||||
lastReasoningStep?.id ??
|
||||
messages.find((message) => message.type === "ai")?.id;
|
||||
return (
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
open={true}
|
||||
>
|
||||
<>
|
||||
<ChainOfThought
|
||||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||||
data-reference-batch-id={reasoningId}
|
||||
open={true}
|
||||
>
|
||||
{aboveLastToolCallSteps.length > 0 && (
|
||||
<Button
|
||||
key="above"
|
||||
@@ -160,10 +183,12 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
<StepReasoningMarkdown
|
||||
content={step.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -220,10 +245,12 @@ export function MessageGroup({
|
||||
<ChainOfThoughtStep
|
||||
key={lastReasoningStep.id}
|
||||
label={
|
||||
<MarkdownContent
|
||||
<StepReasoningMarkdown
|
||||
content={lastReasoningStep.reasoning ?? ""}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
onCitationClick={handleCitationClick}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -231,7 +258,60 @@ export function MessageGroup({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepReasoningMarkdown({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
onCitationClick,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onCitationClick: (referenceId: string) => void;
|
||||
}) {
|
||||
const markdown = useMemo(
|
||||
() =>
|
||||
applyInlineCitations(content, displaySources, { appendSummary: false }),
|
||||
[content, displaySources],
|
||||
);
|
||||
const components = useMemo(() => {
|
||||
if (displaySources.length === 0) return undefined;
|
||||
return {
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return (
|
||||
<CitationLink
|
||||
href={href}
|
||||
{...props}
|
||||
onReferenceClick={onCitationClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <a href={href} {...props} />;
|
||||
},
|
||||
};
|
||||
}, [displaySources.length, onCitationClick]);
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={markdown}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -695,13 +695,6 @@ 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],
|
||||
@@ -735,6 +728,23 @@ 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
|
||||
@@ -770,24 +780,6 @@ 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}
|
||||
@@ -797,7 +789,7 @@ function MessageContent_({
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : contentToDisplay ? (
|
||||
) : (
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
@@ -806,7 +798,7 @@ function MessageContent_({
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
@@ -905,7 +897,7 @@ function FrontendRagSourcesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRoundRagCitationDialog({
|
||||
export function SingleRoundRagCitationDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -46,8 +46,8 @@ import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import {
|
||||
@@ -112,10 +112,10 @@ export function MessageList({
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
onSetupWritingStart,
|
||||
researchApprovalSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
researchApprovalSlot,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
@@ -138,23 +138,23 @@ export function MessageList({
|
||||
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
|
||||
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
/**
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
|
||||
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
|
||||
* 消息。撰写步骤和完成摘要不走此插槽,而是分别复用原生 tool-call 步骤条和
|
||||
* assistant 消息行;不传该 slot 时普通对话/AI 写作零行为变化。
|
||||
*/
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
researchApprovalSlot?: (message: Message) => React.ReactNode;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
/** Host override for downloading virtual/session-owned files in a present-files card. */
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const documentRewritePresentation = useDocumentRewritePresentation();
|
||||
const { t } = useI18n();
|
||||
const threadContext = useContext(ThreadContext);
|
||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||
@@ -194,7 +194,7 @@ export function MessageList({
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
return hiddenMessageIds.size === 0 && externalHidden.size === 0
|
||||
@@ -205,9 +205,9 @@ export function MessageList({
|
||||
(!hiddenMessageIds.has(m.id) && !externalHidden.has(m.id)),
|
||||
);
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
@@ -255,15 +255,15 @@ export function MessageList({
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const hasSingleRoundRag = useMemo(
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() =>
|
||||
messages.some(
|
||||
(message) =>
|
||||
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
@@ -322,6 +322,32 @@ export function MessageList({
|
||||
return map;
|
||||
}, [thread.messages]);
|
||||
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
const frontendRagSources = msg.id
|
||||
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||
: [];
|
||||
if (batchSources.length > 0) return batchSources;
|
||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
});
|
||||
},
|
||||
[
|
||||
frontendRagSourcesByAiId,
|
||||
messages,
|
||||
referenceBatches,
|
||||
referencesEnabled,
|
||||
skillDisplayMap,
|
||||
],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
@@ -436,47 +462,29 @@ export function MessageList({
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
hasSingleRoundRag ? "gap-4 pt-4" : "gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
thread.isLoading ||
|
||||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
|
||||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
|
||||
const isAssistantMessage =
|
||||
return group.messages.map((msg) => {
|
||||
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
|
||||
// 便于 Streamdown 在每次 summary_delta 到达时立即更新该普通消息行。
|
||||
const messageIsLoading =
|
||||
thread.isLoading ||
|
||||
msg.additional_kwargs?.deep_research_summary_streaming === true ||
|
||||
msg.additional_kwargs?.deep_research_follow_up_streaming === true;
|
||||
const isAssistantMessage =
|
||||
referencesEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
? referenceBatches.find(
|
||||
(batch) => batch.assistant_message_id === msg.id,
|
||||
)?.sources ?? []
|
||||
: [];
|
||||
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
|
||||
const frontendRagSources =
|
||||
isAssistantMessage && msg.id
|
||||
? frontendRagSourcesByAiId.get(msg.id) ?? []
|
||||
: [];
|
||||
const displaySources = isAssistantMessage
|
||||
? batchSources.length > 0
|
||||
? batchSources
|
||||
: frontendRagSources.length > 0
|
||||
? frontendRagSources
|
||||
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
? resolveDisplaySources(msg)
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
@@ -486,9 +494,9 @@ export function MessageList({
|
||||
msg.type === "human"
|
||||
? getQuestionAnchorId(msg, messages.indexOf(msg))
|
||||
: undefined
|
||||
}
|
||||
message={msg}
|
||||
isLoading={messageIsLoading}
|
||||
}
|
||||
message={msg}
|
||||
isLoading={messageIsLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
@@ -584,8 +592,8 @@ export function MessageList({
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:research-approval") {
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
|
||||
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
|
||||
const message = group.messages[0];
|
||||
return researchApprovalSlot && message ? (
|
||||
<div key={group.id} className="w-full pb-2">
|
||||
@@ -611,11 +619,11 @@ export function MessageList({
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
files={files}
|
||||
threadId={threadId}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -683,6 +691,7 @@ export function MessageList({
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -726,11 +735,15 @@ export function MessageList({
|
||||
message.type === "ai" &&
|
||||
(hasToolCalls(message) ? true : !hasContent(message)),
|
||||
);
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -739,11 +752,11 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{documentRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
})}
|
||||
{documentRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -100,6 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/deep-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
{
|
||||
id: "workspace-enterprise-research",
|
||||
label: "企业深度研究",
|
||||
icon: FileSearch,
|
||||
path: "/page/workspace/enterprise-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
|
||||
},
|
||||
{
|
||||
id: "workspace-roundtable-planning",
|
||||
label: "多智能体规划",
|
||||
|
||||
@@ -62,8 +62,6 @@ export interface Agent {
|
||||
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
||||
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
|
||||
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[];
|
||||
@@ -101,8 +99,6 @@ export interface CreateAgentRequest {
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
/** 岗位职责;缺省/空 = 未填写。 */
|
||||
job_responsibilities?: string | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
|
||||
@@ -121,7 +117,5 @@ export interface UpdateAgentRequest {
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
/** 岗位职责;传空字符串表示清除。 */
|
||||
job_responsibilities?: string | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
FileBox,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
@@ -99,6 +100,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Telescope,
|
||||
path: "/page/strategy/qa/deep-research",
|
||||
},
|
||||
{
|
||||
id: "qa-enterprise-research",
|
||||
label: "企业深度研究",
|
||||
icon: FileSearch,
|
||||
path: "/page/strategy/qa/enterprise-research",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -417,6 +417,7 @@ function EightBfAgentWorkspace({
|
||||
showRecentChats={false}
|
||||
avatarSrc={agentConfigLogo}
|
||||
positionName={positionNameById(selectedSeat?.position_id)}
|
||||
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
|
||||
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
|
||||
skillsTitle="技能列表"
|
||||
showCommonQuestions
|
||||
@@ -502,10 +503,12 @@ function EightBfAgentRow({
|
||||
}) {
|
||||
const name = agent?.name || seat.name || "未命名智能体";
|
||||
const description = agent?.description || seat.description || "智能体助手";
|
||||
const avatarText = Array.from(name.trim() || "?")[0]?.toUpperCase() || "?";
|
||||
const avatarText = firstAgentAvatarChar(name);
|
||||
return (
|
||||
<button type="button" className="eightbf-roundtable__agent-selector-card" onClick={onSelect}>
|
||||
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">{avatarText}</span>
|
||||
<span className="eightbf-roundtable__agent-selector-avatar" aria-hidden="true">
|
||||
{avatarText ? avatarText : <img src={agentConfigLogo} alt="" />}
|
||||
</span>
|
||||
<span className="eightbf-roundtable__agent-selector-main">
|
||||
<strong>{name}</strong>
|
||||
<p>{description}</p>
|
||||
@@ -515,6 +518,13 @@ function EightBfAgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 名称首字若是【、( 这类标点,列表卡片改用智能体 logo,不把标点当头像。 */
|
||||
function firstAgentAvatarChar(name: string): string | null {
|
||||
const first = Array.from(name.trim())[0];
|
||||
if (!first || /^[\p{P}\p{S}]$/u.test(first)) return null;
|
||||
return first.toUpperCase();
|
||||
}
|
||||
|
||||
function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> {
|
||||
const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null;
|
||||
if (!source) return [];
|
||||
|
||||
@@ -248,8 +248,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundtablePlanningPage
|
||||
// 仅将 8BF 会商页锁为深蓝色:全局主题状态和切换逻辑保持原样,
|
||||
// 后续去掉本容器与 forceDarkAppearance 即可恢复随主题切换。
|
||||
<div className="dark flex h-full min-h-0 flex-col bg-[#061b3b]">
|
||||
<RoundtablePlanningPage
|
||||
compactChrome={compactChrome}
|
||||
forceDarkAppearance
|
||||
lockedChainId={mapping?.chainId ?? null}
|
||||
lockBusinessChain
|
||||
storageTaskId={storageTaskId}
|
||||
@@ -326,6 +330,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
||||
}
|
||||
rootClassName="eightbf-roundtable"
|
||||
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -300,6 +300,11 @@
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-avatar img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.eightbf-roundtable .eightbf-roundtable__agent-selector-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
BookOpenCheck,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
FileText,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Square,
|
||||
Sparkles,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ModelNameSelect } from "@/components/workspace/model-name-select";
|
||||
import { DownloadWordButton } from "@/components/workspace/messages/download-word-button";
|
||||
import { DocumentRewriteDiffDialog } from "@/components/workspace/messages/document-rewrite-diff-dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { openKnowledgeSource } from "@/core/llmwiki/source-links";
|
||||
import MarkdownRenderer from "@/strategy-components/MarkdownRenderer";
|
||||
import {
|
||||
type LlmWikiSearchResult,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
type EnterpriseResearchTask,
|
||||
type EnterpriseResearchTaskStatus,
|
||||
type EnterpriseResearchReportEvent,
|
||||
type EnterpriseResearchReportJob,
|
||||
collectEnterpriseResearchTask,
|
||||
cancelEnterpriseResearchReportJob,
|
||||
createEnterpriseResearchAiPlan,
|
||||
createEnterpriseResearchTask,
|
||||
createEnterpriseResearchPlanDraft,
|
||||
createEnterpriseResearchReportJob,
|
||||
listEnterpriseResearchTasks,
|
||||
listEnterpriseResearchReportJobs,
|
||||
restoreEnterpriseResearchReportVersion,
|
||||
streamEnterpriseResearchReportJob,
|
||||
updateEnterpriseResearchPlan,
|
||||
} from "@/strategy-components/api/enterprise-research";
|
||||
|
||||
type TemplateId = "topic" | "event" | "policy";
|
||||
type TaskStatus = EnterpriseResearchTaskStatus;
|
||||
|
||||
type ResearchTemplate = {
|
||||
id: TemplateId;
|
||||
name: string;
|
||||
description: string;
|
||||
stages: string[];
|
||||
buildQueries: (subject: string, focus: string) => string[];
|
||||
};
|
||||
|
||||
const TEMPLATES: ResearchTemplate[] = [
|
||||
{
|
||||
id: "topic",
|
||||
name: "通用议题研究",
|
||||
description: "先梳理事实与背景,再归纳关键分歧、影响与待验证问题。",
|
||||
stages: ["界定研究问题", "检索内部证据", "归纳观点与分歧", "形成研究任务包"],
|
||||
buildQueries: (subject, focus) => [subject, `${subject} 背景 关键事实`, focus ? `${subject} ${focus}` : `${subject} 影响 风险 机会`],
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
name: "事件影响评估",
|
||||
description: "适用于突发事件、舆情事件或专项行动的影响研判。",
|
||||
stages: ["还原事件时间线", "识别相关主体", "评估影响传导", "输出风险与跟踪项"],
|
||||
buildQueries: (subject, focus) => [
|
||||
`${subject} 时间线 经过`,
|
||||
`${subject} 相关主体 公开表态`,
|
||||
focus ? `${subject} ${focus} 影响 风险` : `${subject} 影响 风险 应对`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "policy",
|
||||
name: "政策与行业研究",
|
||||
description: "适用于政策、产业、市场等需比较变化与传导机制的主题。",
|
||||
stages: ["明确政策或产业边界", "对比历史与现状", "拆解传导机制", "沉淀研判与建议"],
|
||||
buildQueries: (subject, focus) => [
|
||||
`${subject} 政策 现状`,
|
||||
`${subject} 发展趋势 数据`,
|
||||
focus ? `${subject} ${focus} 行业影响` : `${subject} 影响 挑战 机遇`,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_META: Record<TaskStatus, { label: string; className: string }> = {
|
||||
draft: { label: "草稿", className: "bg-muted text-muted-foreground" },
|
||||
collecting: { label: "检索中", className: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200" },
|
||||
ready: { label: "证据已就绪", className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-200" },
|
||||
partial: { label: "部分完成", className: "bg-orange-100 text-orange-800 dark:bg-orange-950/60 dark:text-orange-200" },
|
||||
failed: { label: "检索失败", className: "bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-200" },
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "刚刚";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "刚刚";
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(parsed);
|
||||
}
|
||||
|
||||
function getTemplate(id: TemplateId) {
|
||||
return TEMPLATES.find((item) => item.id === id) ?? TEMPLATES[0];
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: TaskStatus }) {
|
||||
const meta = STATUS_META[status];
|
||||
return <Badge className={cn("border-0", meta.className)}>{meta.label}</Badge>;
|
||||
}
|
||||
|
||||
export default function EnterpriseResearchPage() {
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const knowledgeBases = useLlmWikiKnowledgeBases("all", runtime.data?.provider === "weknora");
|
||||
const [tasks, setTasks] = useState<EnterpriseResearchTask[]>([]);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [focus, setFocus] = useState("");
|
||||
const [templateId, setTemplateId] = useState<TemplateId>("topic");
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
const [reportJob, setReportJob] = useState<EnterpriseResearchReportJob | null>(null);
|
||||
const [reportJobs, setReportJobs] = useState<EnterpriseResearchReportJob[]>([]);
|
||||
const [reportDraft, setReportDraft] = useState("");
|
||||
const [jobDraft, setJobDraft] = useState("");
|
||||
const [reasoning, setReasoning] = useState("");
|
||||
const [isStartingReport, setIsStartingReport] = useState(false);
|
||||
const reportActionRef = useRef<EnterpriseResearchReportJob["action"]>("initial");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void listEnterpriseResearchTasks()
|
||||
.then((stored) => {
|
||||
if (!alive) return;
|
||||
setTasks(stored);
|
||||
setActiveTaskId(stored[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) toast.error("研究任务读取失败,请稍后重试");
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const activeTask = useMemo(
|
||||
() => tasks.find((task) => task.id === activeTaskId) ?? null,
|
||||
[activeTaskId, tasks],
|
||||
);
|
||||
const template = getTemplate(templateId);
|
||||
const selectedSource = activeTask?.sources.find((source) => source.chunk_id === selectedSourceId) ?? activeTask?.sources[0] ?? null;
|
||||
const isWeKnoraReady = runtime.data?.provider === "weknora";
|
||||
|
||||
const consumeReportStream = (taskId: string, jobId: string, signal?: AbortSignal) => streamEnterpriseResearchReportJob(taskId, jobId, {
|
||||
signal,
|
||||
onEvent: (event: EnterpriseResearchReportEvent) => {
|
||||
if (event.type === "snapshot") {
|
||||
setReportJob(event.job);
|
||||
reportActionRef.current = event.job.action;
|
||||
if (event.job.action === "followup") setJobDraft(event.job.report_markdown || "");
|
||||
else setReportDraft(event.job.report_markdown || "");
|
||||
return;
|
||||
}
|
||||
if (event.type === "report_delta") {
|
||||
if (reportActionRef.current === "followup") setJobDraft((current) => current + event.delta);
|
||||
else setReportDraft((current) => current + event.delta);
|
||||
return;
|
||||
}
|
||||
if (event.type === "reasoning_delta") {
|
||||
setReasoning((current) => (current + event.delta).slice(-6_000));
|
||||
return;
|
||||
}
|
||||
if (event.type === "phase") {
|
||||
setReportJob((current) => current ? { ...current, status: event.status, phase: event.phase } : current);
|
||||
return;
|
||||
}
|
||||
if (event.type === "completed" || event.type === "failed" || event.type === "cancelled") {
|
||||
if (!event.job) return;
|
||||
setReportJob(event.job);
|
||||
setReportJobs((current) => [event.job!, ...current.filter((item) => item.id !== event.job!.id)]);
|
||||
if (event.job.action === "followup") {
|
||||
setJobDraft(event.job.report_markdown || "");
|
||||
} else if (event.type === "completed") {
|
||||
setReportDraft(event.job.report_markdown || "");
|
||||
setTasks((current) => current.map((task) => task.id === taskId ? {
|
||||
...task,
|
||||
report_markdown: event.job?.report_markdown ?? task.report_markdown,
|
||||
report_summary: event.job?.report_summary ?? task.report_summary,
|
||||
report_model_name: event.job?.model_name ?? task.report_model_name,
|
||||
active_report_job_id: null,
|
||||
} : task));
|
||||
} else {
|
||||
// A failed or cancelled rewrite may contain a persisted temporary
|
||||
// draft. Never expose it as the committed report in local state.
|
||||
setReportDraft(event.job.original_report || tasks.find((task) => task.id === taskId)?.report_markdown || "");
|
||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||
}
|
||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
||||
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
|
||||
}
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
if ((error as { name?: string } | null)?.name !== "AbortError") {
|
||||
toast.error(error instanceof Error ? error.message : "报告流连接中断");
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTaskId) {
|
||||
setReportJob(null);
|
||||
setReportJobs([]);
|
||||
setReportDraft("");
|
||||
setJobDraft("");
|
||||
setReasoning("");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
let alive = true;
|
||||
setReportJob(null);
|
||||
setReportJobs([]);
|
||||
setReportDraft("");
|
||||
setJobDraft("");
|
||||
setReasoning("");
|
||||
setIsStartingReport(false);
|
||||
void listEnterpriseResearchReportJobs(activeTaskId)
|
||||
.then((jobs) => {
|
||||
if (!alive) return;
|
||||
const latest = jobs[0] ?? null;
|
||||
setReportJobs(jobs);
|
||||
setReportJob(latest);
|
||||
const latestReport = jobs.find((job) => job.status === "completed" && job.action !== "followup");
|
||||
if (latestReport?.report_markdown) setReportDraft(latestReport.report_markdown);
|
||||
const latestFollowup = jobs.find((job) => job.status === "completed" && job.action === "followup");
|
||||
if (latestFollowup?.report_markdown) setJobDraft(latestFollowup.report_markdown);
|
||||
if (latest && (latest.status === "queued" || latest.status === "running")) {
|
||||
reportActionRef.current = latest.action;
|
||||
void consumeReportStream(activeTaskId, latest.id, controller.signal);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) toast.error("报告任务读取失败,请稍后重试");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
// The stream is intentionally scoped to the selected task only. It must not
|
||||
// restart whenever token deltas update the task list.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTaskId]);
|
||||
|
||||
const resetComposer = () => {
|
||||
setActiveTaskId(null);
|
||||
setSubject("");
|
||||
setFocus("");
|
||||
setTemplateId("topic");
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setSelectedSourceId(null);
|
||||
setReportJob(null);
|
||||
setReportJobs([]);
|
||||
setReportDraft("");
|
||||
setJobDraft("");
|
||||
setReasoning("");
|
||||
};
|
||||
|
||||
const toggleKnowledgeBase = (id: string, checked: boolean) => {
|
||||
setSelectedKnowledgeBaseIds((current) => checked ? [...new Set([...current, id])] : current.filter((item) => item !== id));
|
||||
};
|
||||
|
||||
const startResearch = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedSubject = subject.trim();
|
||||
if (!normalizedSubject) {
|
||||
toast.error("请先填写研究主题");
|
||||
return;
|
||||
}
|
||||
if (!isWeKnoraReady) {
|
||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTemplate = getTemplate(templateId);
|
||||
const queries = [...new Set(selectedTemplate.buildQueries(normalizedSubject, focus.trim()).map((item) => item.trim()).filter(Boolean))];
|
||||
try {
|
||||
const created = await createEnterpriseResearchTask({
|
||||
subject: normalizedSubject,
|
||||
focus: focus.trim(),
|
||||
template_id: templateId,
|
||||
knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
queries,
|
||||
});
|
||||
const collecting: EnterpriseResearchTask = { ...created, status: "collecting" };
|
||||
setTasks((current) => [collecting, ...current.filter((item) => item.id !== collecting.id)].slice(0, 20));
|
||||
setActiveTaskId(created.id);
|
||||
setSelectedSourceId(null);
|
||||
|
||||
const completed = await collectEnterpriseResearchTask(created.id);
|
||||
setTasks((current) => current.map((item) => item.id === completed.id ? completed : item));
|
||||
if (completed.sources[0]) setSelectedSourceId(completed.sources[0].chunk_id);
|
||||
if (completed.status === "failed") toast.error("未检索到可用证据");
|
||||
else toast.success(`已完成证据收集:${completed.sources.length} 条资料`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建研究任务失败,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const createPlanDraft = async (taskId: string) => {
|
||||
const updated = await createEnterpriseResearchPlanDraft(taskId);
|
||||
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
return updated;
|
||||
};
|
||||
|
||||
const createAiPlan = async (taskId: string, input: { model_name?: string | null; instruction?: string }) => {
|
||||
const updated = await createEnterpriseResearchAiPlan(taskId, input);
|
||||
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
return updated;
|
||||
};
|
||||
|
||||
const savePlan = async (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => {
|
||||
const updated = await updateEnterpriseResearchPlan(taskId, input);
|
||||
setTasks((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
return updated;
|
||||
};
|
||||
|
||||
const startReport = async (
|
||||
taskId: string,
|
||||
input: Parameters<typeof createEnterpriseResearchReportJob>[1] = {},
|
||||
) => {
|
||||
if (isStartingReport) return;
|
||||
setIsStartingReport(true);
|
||||
try {
|
||||
const job = await createEnterpriseResearchReportJob(taskId, input);
|
||||
reportActionRef.current = job.action;
|
||||
setReportJob(job);
|
||||
setReportJobs((current) => [job, ...current.filter((item) => item.id !== job.id)]);
|
||||
if (job.action === "followup") setJobDraft(job.report_markdown || "");
|
||||
else setReportDraft(job.report_markdown || "");
|
||||
setReasoning("");
|
||||
void consumeReportStream(taskId, job.id);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "创建报告写作任务失败");
|
||||
} finally {
|
||||
setIsStartingReport(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReport = async (taskId: string, jobId: string) => {
|
||||
const cancelled = await cancelEnterpriseResearchReportJob(taskId, jobId);
|
||||
setReportJob(cancelled);
|
||||
setReportJobs((current) => [cancelled, ...current.filter((item) => item.id !== cancelled.id)]);
|
||||
if (cancelled.action === "followup") setJobDraft("");
|
||||
else setReportDraft(tasks.find((task) => task.id === taskId)?.report_markdown || "");
|
||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||
toast.success("已取消报告任务,已保存的正式报告未受影响");
|
||||
};
|
||||
|
||||
const restoreReport = async (taskId: string, jobId: string) => {
|
||||
const updated = await restoreEnterpriseResearchReportVersion(taskId, jobId);
|
||||
const jobs = await listEnterpriseResearchReportJobs(taskId);
|
||||
setTasks((current) => current.map((task) => task.id === updated.id ? updated : task));
|
||||
setReportJobs(jobs);
|
||||
setReportJob(jobs[0] ?? null);
|
||||
setReportDraft(updated.report_markdown || "");
|
||||
toast.success("已恢复改写前版本");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<header className="flex shrink-0 items-center justify-between border-b bg-card px-5 py-3.5 sm:px-7">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileSearch className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-base font-semibold sm:text-lg">企业深度研究</h1>
|
||||
<Badge variant="outline" className="text-muted-foreground">独立工作台</Badge>
|
||||
</div>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">面向内部知识库的研究任务、证据编排与可追溯交付</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={resetComposer}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
新建任务
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[272px_minmax(440px,0.9fr)_minmax(420px,1.1fr)]">
|
||||
<aside className="min-h-0 border-b bg-muted/20 xl:border-r xl:border-b-0">
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">研究任务</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">服务端保留最近 20 条</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{tasks.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="h-[220px] xl:h-[calc(100%-76px)]">
|
||||
<div className="space-y-1 px-3 pb-4">
|
||||
{tasks.length ? tasks.map((task) => (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTaskId(task.id); setSelectedSourceId(task.sources[0]?.chunk_id ?? null); }}
|
||||
className={cn(
|
||||
"w-full rounded-lg border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
activeTaskId === task.id ? "border-primary/35 bg-primary/8" : "border-transparent hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="line-clamp-2 text-sm font-medium leading-5">{task.subject}</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{task.sources.length} 条资料</span><span>·</span><span>{formatTime(task.updated_at || task.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">尚无任务。创建第一个研究任务开始。</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
|
||||
<main className="min-h-0 overflow-y-auto border-b p-5 xl:border-r xl:border-b-0 xl:p-6">
|
||||
{!activeTaskId ? (
|
||||
<form className="mx-auto max-w-2xl space-y-5" onSubmit={startResearch}>
|
||||
<section>
|
||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="text-base">研究对象与范围</CardTitle>
|
||||
<CardDescription>用一句话明确需要研究的对象;补充范围可帮助生成更聚焦的检索方向。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-subject">
|
||||
研究主题 <span className="text-destructive">*</span>
|
||||
<Input id="enterprise-research-subject" value={subject} onChange={(event) => setSubject(event.target.value)} placeholder="例如:新能源汽车产业链的供应风险" maxLength={180} />
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm font-medium" htmlFor="enterprise-research-focus">
|
||||
重点范围 <span className="font-normal text-muted-foreground">(可选)</span>
|
||||
<Textarea id="enterprise-research-focus" value={focus} onChange={(event) => setFocus(event.target.value)} placeholder="例如:聚焦 2025 年以来的海外供应、关键原材料与对国内产能的影响" className="min-h-22 resize-y" maxLength={800} />
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="text-base">选择研究模板</CardTitle>
|
||||
<CardDescription>模板只决定问题拆解和证据编排方式,不会替换你的原始问题。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
{TEMPLATES.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
onClick={() => setTemplateId(item.id)}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
templateId === item.id ? "border-primary bg-primary/5" : "hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm font-semibold">{item.name}</span>
|
||||
<span className="mt-1.5 block text-xs leading-5 text-muted-foreground">{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base"><Database className="size-4 text-primary" />证据范围</CardTitle>
|
||||
<CardDescription>{isWeKnoraReady ? "只显示当前账号已授权的知识库。未选择时会检索全部可见知识库。" : "知识服务未启用,无法开始内部证据检索。"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{knowledgeBases.isLoading ? <div className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />正在读取知识库…</div> : knowledgeBases.isError ? <div className="flex items-center gap-2 text-sm text-destructive"><AlertCircle className="size-4" />知识库读取失败</div> : (
|
||||
<div className="space-y-2">
|
||||
{knowledgeBases.data?.length ? knowledgeBases.data.map((base) => {
|
||||
const checked = selectedKnowledgeBaseIds.includes(base.id);
|
||||
return <label key={base.id} className="flex cursor-pointer items-start gap-3 rounded-md border p-3 hover:bg-accent/60">
|
||||
<Checkbox checked={checked} onCheckedChange={(next) => toggleKnowledgeBase(base.id, next)} aria-label={`选择知识库 ${base.name}`} />
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{base.name}</span><span className="mt-0.5 block line-clamp-1 text-xs text-muted-foreground">{base.description || `${base.knowledge_count} 份资料`}</span></span>
|
||||
<span className="text-xs text-muted-foreground">{base.chunk_count} 段</span>
|
||||
</label>;
|
||||
}) : <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">当前账号没有可用知识库,可先前往“知识库管理”添加或发布资料。</p>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm"><span className="font-medium">即将检索 {template.stages.length - 1} 个研究方向</span><p className="mt-1 text-xs text-muted-foreground">会生成主题、背景与重点范围三组可见检索词。</p></div>
|
||||
<Button type="submit" disabled={!isWeKnoraReady || knowledgeBases.isLoading}><Play className="mr-1.5 size-4" />开始证据编排</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : activeTask ? (
|
||||
<TaskOverview task={activeTask} reportJob={reportJob} isStartingReport={isStartingReport} onNew={resetComposer} onCreatePlanDraft={createPlanDraft} onCreateAiPlan={createAiPlan} onSavePlan={savePlan} />
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto bg-muted/10 p-5 xl:p-6">
|
||||
<EvidencePane task={activeTask} source={selectedSource} reportJob={reportJob} reportJobs={reportJobs} reportDraft={reportDraft} jobDraft={jobDraft} reasoning={reasoning} isStartingReport={isStartingReport} onStartReport={startReport} onCancelReport={cancelReport} onRestoreReport={restoreReport} onSourceSelect={setSelectedSourceId} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskOverview({
|
||||
task,
|
||||
reportJob,
|
||||
isStartingReport,
|
||||
onNew,
|
||||
onCreatePlanDraft,
|
||||
onCreateAiPlan,
|
||||
onSavePlan,
|
||||
}: {
|
||||
task: EnterpriseResearchTask;
|
||||
reportJob: EnterpriseResearchReportJob | null;
|
||||
isStartingReport: boolean;
|
||||
onNew: () => void;
|
||||
onCreatePlanDraft: (taskId: string) => Promise<EnterpriseResearchTask>;
|
||||
onCreateAiPlan: (taskId: string, input: { model_name?: string | null; instruction?: string }) => Promise<EnterpriseResearchTask>;
|
||||
onSavePlan: (taskId: string, input: { plan_markdown: string; selected_source_ids: string[] }) => Promise<EnterpriseResearchTask>;
|
||||
}) {
|
||||
const template = getTemplate(task.template_id as TemplateId);
|
||||
const [planMarkdown, setPlanMarkdown] = useState(task.plan_markdown ?? "");
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>(task.selected_source_ids ?? []);
|
||||
const [isCreatingPlan, setIsCreatingPlan] = useState(false);
|
||||
const [isSavingPlan, setIsSavingPlan] = useState(false);
|
||||
const [isCreatingAiPlan, setIsCreatingAiPlan] = useState(false);
|
||||
const [planModel, setPlanModel] = useState<string | null>(null);
|
||||
const [planInstruction, setPlanInstruction] = useState("");
|
||||
const canPlan = task.status === "ready" || task.status === "partial";
|
||||
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
|
||||
|
||||
useEffect(() => {
|
||||
setPlanMarkdown(task.plan_markdown ?? "");
|
||||
setSelectedSourceIds(task.selected_source_ids ?? []);
|
||||
}, [task.id, task.plan_markdown, task.selected_source_ids]);
|
||||
|
||||
const toggleSource = (sourceId: string, checked: boolean) => {
|
||||
setSelectedSourceIds((current) => checked ? [...new Set([...current, sourceId])] : current.filter((item) => item !== sourceId));
|
||||
};
|
||||
|
||||
const handleCreatePlan = async () => {
|
||||
setIsCreatingPlan(true);
|
||||
try {
|
||||
const updated = await onCreatePlanDraft(task.id);
|
||||
toast.success(`已根据 ${updated.selected_source_ids.length} 条资料生成可编辑研究计划`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成研究计划失败");
|
||||
} finally {
|
||||
setIsCreatingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
setIsSavingPlan(true);
|
||||
try {
|
||||
const updated = await onSavePlan(task.id, { plan_markdown: planMarkdown, selected_source_ids: selectedSourceIds });
|
||||
toast.success(`研究计划已保存,已选 ${updated.selected_source_ids.length} 条资料`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存研究计划失败");
|
||||
} finally {
|
||||
setIsSavingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAiPlan = async () => {
|
||||
setIsCreatingAiPlan(true);
|
||||
try {
|
||||
const updated = await onCreateAiPlan(task.id, { model_name: planModel, instruction: planInstruction.trim() });
|
||||
setPlanMarkdown(updated.plan_markdown ?? "");
|
||||
setSelectedSourceIds(updated.selected_source_ids ?? []);
|
||||
toast.success("AI 研究计划已生成,可继续人工修改后保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "AI 规划失败");
|
||||
} finally {
|
||||
setIsCreatingAiPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="mx-auto max-w-2xl space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><Badge variant="secondary" className="mb-3"><ClipboardList className="size-3" />研究任务</Badge><h2 className="text-xl font-semibold tracking-tight">{task.subject}</h2>{task.focus ? <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">{task.focus}</p> : null}</div>
|
||||
<div className="flex items-center gap-2"><StatusBadge status={task.status} /><Button size="sm" variant="outline" onClick={onNew}><Plus className="mr-1 size-4" />新建</Button></div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究路径</CardTitle><CardDescription>{template.name} · 每一步保留用户可核验的检索词和证据来源。</CardDescription></CardHeader>
|
||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||
const current = task.status === "collecting" && index === 1;
|
||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||
})}</ol></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
|
||||
</Card>
|
||||
{canPlan ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!task.plan_markdown ? <div className="rounded-lg border border-dashed bg-muted/30 p-4"><p className="text-sm font-medium">证据已收集,可生成一份可编辑的研究计划</p><p className="mt-1 text-xs leading-5 text-muted-foreground">初版会按当前研究模板组织目标、核心问题、章节和写作约束;生成报告前仍可修改。</p><Button className="mt-3" size="sm" onClick={handleCreatePlan} disabled={isCreatingPlan}><Sparkles className="mr-1.5 size-4" />{isCreatingPlan ? "正在生成…" : "生成研究计划"}</Button></div> : <>
|
||||
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><label htmlFor={`enterprise-plan-${task.id}`} className="text-sm font-medium">可编辑研究计划</label><span className="text-xs text-muted-foreground">{planMarkdown.length} / 16,000</span></div><Textarea id={`enterprise-plan-${task.id}`} value={planMarkdown} onChange={(event) => setPlanMarkdown(event.target.value)} className="min-h-80 resize-y font-mono text-xs leading-6" maxLength={16_000} /></div>
|
||||
<div className="space-y-2"><div className="flex items-center justify-between gap-3"><p className="text-sm font-medium">选用资料</p><span className="text-xs text-muted-foreground">已选 {selectedSourceIds.length} / {task.sources.length}</span></div><ScrollArea className="h-44 rounded-md border"><div className="space-y-1 p-2">{task.sources.map((source, index) => { const checked = selectedSourceIds.includes(source.chunk_id); return <label key={source.chunk_id || `${source.knowledge_id}-${index}`} className="flex cursor-pointer items-start gap-2 rounded-md px-2 py-2 hover:bg-muted"><Checkbox checked={checked} onCheckedChange={(next) => toggleSource(source.chunk_id, next === true)} aria-label={`选用资料 ${source.title || source.filename || index + 1}`} /><span className="min-w-0 text-xs leading-5"><span className="block truncate font-medium">{source.title || source.filename || "未命名资料"}</span><span className="block truncate text-muted-foreground">{source.knowledge_base_name || "知识库"}</span></span></label>; })}</div></ScrollArea></div>
|
||||
<div className="rounded-lg border bg-muted/20 p-3"><div className="flex flex-wrap items-center gap-2"><ModelNameSelect value={planModel} onChange={setPlanModel} disabled={isCreatingAiPlan || isWriting} triggerClassName="w-40" /><Input value={planInstruction} onChange={(event) => setPlanInstruction(event.target.value)} placeholder="AI 规划补充要求(可选)" className="h-8 min-w-48 flex-1 text-xs" maxLength={2_000} /><Button size="sm" variant="outline" onClick={handleCreateAiPlan} disabled={isCreatingAiPlan || isWriting}><WandSparkles className="mr-1.5 size-4" />{isCreatingAiPlan ? "正在规划…" : "AI 优化计划"}</Button></div></div>
|
||||
<div className="flex justify-end"><Button size="sm" onClick={handleSavePlan} disabled={isSavingPlan || !planMarkdown.trim() || isWriting}>{isSavingPlan ? "正在保存…" : "保存研究计划"}</Button></div>
|
||||
{reportJob ? <div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground"><span className="font-medium text-foreground">报告任务:</span>{reportJob.status === "completed" ? "已完成" : reportJob.status === "failed" ? `失败:${reportJob.error_message || "请稍后重试"}` : `正在${reportJob.phase === "understanding" ? "理解研究计划" : reportJob.phase === "reviewing" ? "校验报告" : "流式写入 Markdown"}`};仅使用本任务已保存的计划与已选资料快照。</div> : null}
|
||||
</>}
|
||||
</CardContent>
|
||||
</Card> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EvidencePane({
|
||||
task, source, reportJob, reportJobs, reportDraft, jobDraft, reasoning, isStartingReport,
|
||||
onStartReport, onCancelReport, onRestoreReport, onSourceSelect,
|
||||
}: {
|
||||
task: EnterpriseResearchTask | null;
|
||||
source: LlmWikiSearchResult | null;
|
||||
reportJob: EnterpriseResearchReportJob | null;
|
||||
reportJobs: EnterpriseResearchReportJob[];
|
||||
reportDraft: string;
|
||||
jobDraft: string;
|
||||
reasoning: string;
|
||||
isStartingReport: boolean;
|
||||
onStartReport: (taskId: string, input?: Parameters<typeof createEnterpriseResearchReportJob>[1]) => Promise<void>;
|
||||
onCancelReport: (taskId: string, jobId: string) => Promise<void>;
|
||||
onRestoreReport: (taskId: string, jobId: string) => Promise<void>;
|
||||
onSourceSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const [modelName, setModelName] = useState<string | null>(null);
|
||||
const [style, setStyle] = useState<"formal" | "analytical" | "concise">("formal");
|
||||
const [targetLength, setTargetLength] = useState("5000");
|
||||
const [rewriteInstruction, setRewriteInstruction] = useState("优化结构、逻辑与表达,减少重复内容,并保留事实与引用");
|
||||
const [followup, setFollowup] = useState("");
|
||||
const [diffJob, setDiffJob] = useState<EnterpriseResearchReportJob | null>(null);
|
||||
|
||||
if (!task) return <div className="flex h-full min-h-72 flex-col items-center justify-center rounded-xl border border-dashed p-8 text-center"><div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"><BookOpenCheck className="size-5" /></div><h2 className="mt-4 text-sm font-semibold">等待研究任务</h2><p className="mt-1 max-w-xs text-sm leading-6 text-muted-foreground">提交主题后,这里会展示证据、研究报告和可追溯引用。</p></div>;
|
||||
|
||||
const isCollecting = task.status === "collecting";
|
||||
const isWriting = isStartingReport || reportJob?.status === "queued" || reportJob?.status === "running";
|
||||
const currentReport = isWriting && reportJob?.action !== "followup"
|
||||
? reportDraft
|
||||
: reportDraft || task.report_markdown || "";
|
||||
const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report);
|
||||
const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed");
|
||||
const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed");
|
||||
const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as LlmWikiSearchResult[];
|
||||
const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1]));
|
||||
const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => {
|
||||
const index = citationIndex.get(id.trim());
|
||||
return index ? `[[${index}]]` : match;
|
||||
});
|
||||
const parsedTarget = Number(targetLength);
|
||||
|
||||
const downloadMarkdown = () => {
|
||||
const blob = new Blob([currentReport], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${task.subject.replace(/[\\/:*?"<>|]/g, "-") || "企业研究报告"}.md`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const start = (action: "initial" | "rewrite" | "followup", instruction = "") => onStartReport(task.id, {
|
||||
model_name: modelName,
|
||||
action,
|
||||
instruction,
|
||||
style,
|
||||
target_length: Number.isFinite(parsedTarget) && parsedTarget >= 800 ? parsedTarget : null,
|
||||
});
|
||||
|
||||
const phaseOrder = ["understanding", "writing", "reviewing", "completed"];
|
||||
const phaseIndex = reportJob ? phaseOrder.indexOf(reportJob.phase) : -1;
|
||||
|
||||
return <div className="space-y-4">
|
||||
{task.plan_markdown && !currentReport && !isWriting ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-sm">报告写作配置</CardTitle><CardDescription>写作只使用已保存计划和 {task.selected_source_ids.length} 条已选资料。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼摘要</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="报告目标字数" /></div>
|
||||
<Button className="w-full" onClick={() => void start("initial")}><Play className="mr-1.5 size-4" />开始流式撰写报告</Button>
|
||||
</CardContent>
|
||||
</Card> : null}
|
||||
|
||||
{isWriting && reportJob ? <Card>
|
||||
<CardHeader className="gap-1"><div className="flex items-center justify-between gap-3"><CardTitle className="flex items-center gap-2 text-sm"><Loader2 className="size-4 animate-spin text-primary" />{reportJob.action === "followup" ? "正在回答报告追问" : reportJob.action === "rewrite" ? "正在全文改写" : "正在撰写研究报告"}</CardTitle><Button size="sm" variant="outline" onClick={() => void onCancelReport(task.id, reportJob.id)}><Square className="mr-1.5 size-3.5" />停止</Button></div><CardDescription>真实报告只在任务成功完成后提交;取消或失败不会覆盖已保存版本。</CardDescription></CardHeader>
|
||||
<CardContent><ol className="grid gap-2 sm:grid-cols-4">{["理解要求", "流式写入", "校验报告", "保存版本"].map((label, index) => <li key={label} className={cn("rounded-md border px-3 py-2 text-xs", index < phaseIndex ? "border-emerald-200 bg-emerald-50 text-emerald-800" : index === phaseIndex ? "border-primary/40 bg-primary/5 text-primary" : "text-muted-foreground")}><span className="font-medium">{index + 1}. {label}</span></li>)}</ol>{reasoning ? <details className="mt-3 rounded-md border px-3 py-2 text-xs text-muted-foreground"><summary className="cursor-pointer">模型思考过程(不会写入报告)</summary><pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-sans leading-5">{reasoning}</pre></details> : null}</CardContent>
|
||||
</Card> : null}
|
||||
|
||||
{currentReport ? <Card className="overflow-hidden">
|
||||
<CardHeader className="border-b py-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><CardTitle className="flex items-center gap-2 text-sm"><FileText className="size-4 text-primary" />{task.subject}</CardTitle><CardDescription className="mt-1">{latestCommittedJob?.report_summary || task.report_summary || `${currentReport.length} 字`}</CardDescription></div><div className="flex items-center gap-1"><Button size="icon-sm" variant="ghost" onClick={downloadMarkdown} aria-label="下载 Markdown" title="下载 Markdown"><Download className="size-4" /></Button><DownloadWordButton markdown={currentReport} fileName={task.subject} title={task.subject} aria-label="下载 Word" />{latestRewrite ? <Button size="sm" variant="outline" onClick={() => setDiffJob(latestRewrite)}>查看改写对比</Button> : null}</div></div></CardHeader>
|
||||
<CardContent className="p-0"><ScrollArea className="h-[430px]"><article className="p-5"><MarkdownRenderer content={renderedReport} onCitationClick={(index) => onSourceSelect(selectedSources[index - 1]?.chunk_id ?? null)} /></article></ScrollArea></CardContent>
|
||||
</Card> : null}
|
||||
|
||||
{currentReport && !isWriting ? <Card>
|
||||
<CardHeader className="gap-1"><CardTitle className="text-sm">继续完善报告</CardTitle><CardDescription>全文改写会保存原版本;追问只生成回答,不会覆盖报告。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3"><div className="grid gap-2 sm:grid-cols-[160px_120px_1fr]"><ModelNameSelect value={modelName} onChange={setModelName} triggerClassName="w-full" /><Select value={style} onValueChange={(value) => setStyle(value as typeof style)}><SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="formal">正式报告</SelectItem><SelectItem value="analytical">分析研判</SelectItem><SelectItem value="concise">精炼表达</SelectItem></SelectContent></Select><Input type="number" min={800} max={30000} value={targetLength} onChange={(event) => setTargetLength(event.target.value)} className="h-8 text-xs" aria-label="改写目标字数" /></div><Textarea value={rewriteInstruction} onChange={(event) => setRewriteInstruction(event.target.value)} className="min-h-20 text-sm" maxLength={4_000} /><div className="flex justify-end gap-2">{latestRewrite ? <Button variant="outline" onClick={() => void onRestoreReport(task.id, latestRewrite.id)}>恢复最近改写前版本</Button> : null}<Button onClick={() => void start("rewrite", rewriteInstruction)}><WandSparkles className="mr-1.5 size-4" />全文改写</Button></div><div className="flex gap-2 border-t pt-3"><Input value={followup} onChange={(event) => setFollowup(event.target.value)} placeholder="基于当前报告和已选资料继续追问" maxLength={4_000} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && followup.trim()) { event.preventDefault(); void start("followup", followup.trim()); setFollowup(""); } }} /><Button size="icon" disabled={!followup.trim()} onClick={() => { void start("followup", followup.trim()); setFollowup(""); }} aria-label="发送报告追问"><Send className="size-4" /></Button></div></CardContent>
|
||||
</Card> : null}
|
||||
|
||||
{(jobDraft || completedFollowups.length) ? <Card><CardHeader className="gap-1"><CardTitle className="text-sm">报告追问</CardTitle><CardDescription>回答基于当前报告和已选资料,不会自动发起新检索。</CardDescription></CardHeader><CardContent className="space-y-4">{jobDraft ? <div className="rounded-md border bg-muted/20 p-4"><MarkdownRenderer content={jobDraft} /></div> : null}{completedFollowups.slice(0, 3).map((job) => job.id === reportJob?.id && jobDraft ? null : <div key={job.id} className="border-t pt-4"><p className="mb-2 text-xs font-medium text-muted-foreground">{job.instruction}</p><MarkdownRenderer content={job.report_markdown} /></div>)}</CardContent></Card> : null}
|
||||
|
||||
<div className="flex items-start justify-between gap-3"><div><p className="text-sm font-semibold">证据工作台</p><p className="mt-1 text-xs text-muted-foreground">{isCollecting ? "正在收集内部证据…" : `已收集 ${task.sources.length} 条去重资料;报告引用可点击定位`}</p></div>{isCollecting ? <Loader2 className="size-4 animate-spin text-primary" /> : <ShieldCheck className="size-5 text-emerald-600" />}</div>
|
||||
{isCollecting ? <div className="rounded-xl border bg-card p-5 text-sm text-muted-foreground"><div className="flex items-center gap-2"><Loader2 className="size-4 animate-spin" />正在执行 {task.queries.length} 个检索方向</div></div> : task.sources.length ? <div className="grid gap-4 2xl:grid-cols-[minmax(220px,0.8fr)_minmax(260px,1.2fr)]"><div className="rounded-xl border bg-card"><div className="border-b px-4 py-3 text-xs font-medium text-muted-foreground">资料列表</div><ScrollArea className="h-80"><div className="p-2">{task.sources.map((item, index) => <button key={item.chunk_id || `${item.knowledge_id}-${index}`} type="button" onClick={() => onSourceSelect(item.chunk_id)} className={cn("w-full rounded-lg p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", source?.chunk_id === item.chunk_id ? "bg-primary/10" : "hover:bg-muted")}><p className="line-clamp-2 text-sm font-medium leading-5">{index + 1}. {item.title || item.filename || "未命名资料"}</p><p className="mt-1 truncate text-xs text-muted-foreground">{item.knowledge_base_name || "知识库"}</p></button>)}</div></ScrollArea></div><article className="rounded-xl border bg-card p-5"><p className="text-sm font-semibold">{source?.title || source?.filename || "资料预览"}</p><p className="mt-1 text-xs text-muted-foreground">{source?.knowledge_base_name || "知识库"} · 段落 {typeof source?.chunk_index === "number" ? source.chunk_index + 1 : "-"}</p><p className="mt-5 whitespace-pre-wrap text-sm leading-7 text-foreground/90">{source?.content || "请选择一条资料查看内容。"}</p>{source?.url ? <button type="button" className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => { if (!openKnowledgeSource(source)) toast.error("暂时无法定位该资料原文"); }}><ExternalLink className="size-4" />定位知识库原文</button> : null}</article></div> : <div className="rounded-xl border border-dashed bg-card p-6 text-center"><FileText className="mx-auto size-6 text-muted-foreground" /><h2 className="mt-3 text-sm font-semibold">未发现可用资料</h2></div>}
|
||||
|
||||
<DocumentRewriteDiffDialog
|
||||
open={Boolean(diffJob)}
|
||||
onOpenChange={(open) => { if (!open) setDiffJob(null); }}
|
||||
fileName={`${task.subject}.md`}
|
||||
original={diffJob?.original_report || ""}
|
||||
rewritten={diffJob?.report_markdown || ""}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtab
|
||||
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
|
||||
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
|
||||
import DeepResearchPage from "./DeepResearchPage";
|
||||
import EnterpriseResearchPage from "./EnterpriseResearchPage";
|
||||
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
|
||||
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
|
||||
import "@/roundtable-planning/styles/roundtable-planning.css";
|
||||
@@ -117,6 +118,7 @@ export default function StrategyRoutes() {
|
||||
}
|
||||
/>
|
||||
<Route path="qa/deep-research" element={<DeepResearchPage />} />
|
||||
<Route path="qa/enterprise-research" element={<EnterpriseResearchPage />} />
|
||||
|
||||
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
|
||||
<Route
|
||||
|
||||
@@ -63,6 +63,7 @@ import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import DeepResearchPage from './DeepResearchPage';
|
||||
import EnterpriseResearchPage from './EnterpriseResearchPage';
|
||||
import StudioNotebooksPage from './StudioNotebooksPage';
|
||||
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
|
||||
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
|
||||
@@ -563,6 +564,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="enterprise-research"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<EnterpriseResearchPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="studio/notebooks"
|
||||
element={
|
||||
|
||||
@@ -405,6 +405,10 @@ function DeepResearchWorkbenchInner() {
|
||||
const [mode, setMode] = useState<ResearchMode>("basic");
|
||||
const [generateImages, setGenerateImages] = useState(false);
|
||||
const [advancedOptions, setAdvancedOptions] = useState<AdvancedResearchOptions>(DEFAULT_ADVANCED_OPTIONS);
|
||||
// A session's initial config is deliberately frozen when collection starts.
|
||||
// Keep only post-start outline choices separately so another research record
|
||||
// cannot leak its workflow selection into this record's writing card.
|
||||
const [reportCardOutlineOverrides, setReportCardOutlineOverrides] = useState<Record<string, string>>({});
|
||||
const [classicMode, setClassicMode] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -468,6 +472,7 @@ function DeepResearchWorkbenchInner() {
|
||||
: null;
|
||||
const streamJobId = activeJobId ?? lastCompletedJobId;
|
||||
const isFollowUp = session?.status === "completed" && Boolean(session.reportMarkdown);
|
||||
const reportCardOutlineOverride = session ? reportCardOutlineOverrides[session.id] : undefined;
|
||||
const availableModes = useMemo<ResearchMode[]>(
|
||||
() => capabilities.data?.modes ?? DEFAULT_AVAILABLE_MODES,
|
||||
[capabilities.data?.modes],
|
||||
@@ -1435,6 +1440,19 @@ function DeepResearchWorkbenchInner() {
|
||||
document.getElementById("deep-research-composer")?.focus();
|
||||
}, []);
|
||||
|
||||
const updateAdvancedOption = useCallback(
|
||||
<K extends keyof AdvancedResearchOptions>(key: K, value: AdvancedResearchOptions[K]) => {
|
||||
setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions);
|
||||
if (key !== "customOutline" || !session?.id) return;
|
||||
setReportCardOutlineOverrides((current) => (
|
||||
current[session.id] === value
|
||||
? current
|
||||
: { ...current, [session.id]: value as string }
|
||||
));
|
||||
},
|
||||
[session?.id],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await actions.delete.mutateAsync(id);
|
||||
@@ -1566,6 +1584,7 @@ function DeepResearchWorkbenchInner() {
|
||||
availableModes={availableModes}
|
||||
defaultMode={session.mode}
|
||||
defaultOptions={advancedOptionsFromConfig(session.config)}
|
||||
outlineOverride={reportCardOutlineOverride}
|
||||
defaultGenerateImages={session.config?.generate_images === true}
|
||||
modelLabel={modelLabel}
|
||||
materialCount={materialCount}
|
||||
@@ -1769,9 +1788,7 @@ function DeepResearchWorkbenchInner() {
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={Boolean(isFollowUp) || jobWriting}
|
||||
appliedContent={advancedOptions.customOutline}
|
||||
onApply={(content) =>
|
||||
setAdvancedOptions((current) => ({ ...current, customOutline: content }))
|
||||
}
|
||||
onApply={(content) => updateAdvancedOption("customOutline", content)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1855,7 +1872,7 @@ function DeepResearchWorkbenchInner() {
|
||||
onClassicModeChange={setClassicMode}
|
||||
imageGenerationAvailable={imageGenerationAvailable}
|
||||
imageLimit={capabilities.data?.imageGeneration?.maxImages ?? 4}
|
||||
onChange={(key, value) => setAdvancedOptions((current) => ({ ...current, [key]: value }) as AdvancedResearchOptions)}
|
||||
onChange={updateAdvancedOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 以 chat 入口启动报告写作 job —— 保留旧输入框全部能力,改造收敛在这一张卡片。
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ImagePlus,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ResearchMode,
|
||||
type ResearchTone,
|
||||
} from "@/strategy-components/api/deep-research";
|
||||
import { useReportStructures } from "@/strategy-components/api/report-structures";
|
||||
import { WorkflowConfigButton } from "./WorkflowConfigDialog";
|
||||
|
||||
const MODE_LABELS: Record<ResearchMode, string> = {
|
||||
@@ -182,19 +183,16 @@ export const PROGRESSIVE_REPORT_OUTLINE = `# 报告标题
|
||||
|
||||
> 写作要求:除引言外,每个二级小节写 2–5 段完整分析。后章必须明确承接前章的结论;若材料无法支持递进关系,应说明资料限制而非编造衔接。`;
|
||||
|
||||
export type ReportOutlineTemplate = "general" | "progressive" | "custom";
|
||||
const CUSTOM_REPORT_STRUCTURE_VALUE = "__custom_report_structure__";
|
||||
const REPORT_STRUCTURE_VALUE_PREFIX = "structure:";
|
||||
|
||||
const REPORT_OUTLINE_TEMPLATE_OPTIONS: Array<{ value: ReportOutlineTemplate; label: string }> = [
|
||||
{ value: "general", label: "通用分析结构" },
|
||||
{ value: "progressive", label: "递进式论证结构" },
|
||||
{ value: "custom", label: "自定义结构" },
|
||||
];
|
||||
|
||||
function matchingOutlineTemplate(outline: string): ReportOutlineTemplate {
|
||||
function matchingReportStructureId(
|
||||
outline: string,
|
||||
structures: Array<{ id: string; content: string }>,
|
||||
): string | null {
|
||||
const normalized = outline.trim();
|
||||
if (normalized === DEFAULT_REPORT_OUTLINE.trim()) return "general";
|
||||
if (normalized === PROGRESSIVE_REPORT_OUTLINE.trim()) return "progressive";
|
||||
return "custom";
|
||||
if (!normalized) return null;
|
||||
return structures.find((structure) => structure.content.trim() === normalized)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Shared by the initial settings dialog and the write-confirmation card. */
|
||||
@@ -207,19 +205,26 @@ export function ReportOutlineEditor({
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const [customSelected, setCustomSelected] = useState(false);
|
||||
const template = customSelected ? "custom" : matchingOutlineTemplate(value);
|
||||
// 与「工作流配置」共用报告结构库;创建/编辑/启停后 React Query 的失效会
|
||||
// 自动刷新本下拉,避免维护一份会逐渐过时的前端硬编码模板。
|
||||
const structuresQuery = useReportStructures({ status: "enabled" });
|
||||
const structures = structuresQuery.data ?? [];
|
||||
const selectedStructureId = matchingReportStructureId(value, structures);
|
||||
const templateValue = selectedStructureId
|
||||
? `${REPORT_STRUCTURE_VALUE_PREFIX}${selectedStructureId}`
|
||||
: CUSTOM_REPORT_STRUCTURE_VALUE;
|
||||
|
||||
const selectTemplate = (next: ReportOutlineTemplate) => {
|
||||
if (next === "custom") {
|
||||
setCustomSelected(true);
|
||||
// “自定义” is an intentional blank starting point. The two presets are
|
||||
// still available as one-click scaffolds whenever the user wants them.
|
||||
const selectTemplate = (next: string) => {
|
||||
if (next === CUSTOM_REPORT_STRUCTURE_VALUE) {
|
||||
// “自定义” is an intentional blank starting point. Saved structures are
|
||||
// always available as one-click scaffolds whenever the user wants them.
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
setCustomSelected(false);
|
||||
onChange(next === "general" ? DEFAULT_REPORT_OUTLINE : PROGRESSIVE_REPORT_OUTLINE);
|
||||
if (!next.startsWith(REPORT_STRUCTURE_VALUE_PREFIX)) return;
|
||||
const structureId = next.slice(REPORT_STRUCTURE_VALUE_PREFIX.length);
|
||||
const structure = structures.find((item) => item.id === structureId);
|
||||
if (structure) onChange(structure.content);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -227,14 +232,23 @@ export function ReportOutlineEditor({
|
||||
<label htmlFor={`${id}-template`} className="block text-xs font-medium">
|
||||
报告结构模板
|
||||
</label>
|
||||
<Select value={template} onValueChange={(next) => selectTemplate(next as ReportOutlineTemplate)}>
|
||||
<Select value={templateValue} onValueChange={selectTemplate}>
|
||||
<SelectTrigger id={`${id}-template`} className="h-9 text-sm" aria-label="报告结构模板">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_OUTLINE_TEMPLATE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
{structuresQuery.isLoading && (
|
||||
<SelectItem value="__report_structures_loading__" disabled>正在加载工作流结构…</SelectItem>
|
||||
)}
|
||||
{structuresQuery.isError && (
|
||||
<SelectItem value="__report_structures_error__" disabled>报告结构库加载失败</SelectItem>
|
||||
)}
|
||||
{structures.map((structure) => (
|
||||
<SelectItem key={structure.id} value={`${REPORT_STRUCTURE_VALUE_PREFIX}${structure.id}`}>
|
||||
{structure.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_REPORT_STRUCTURE_VALUE}>自定义结构</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label htmlFor={id} className="block pt-1 text-xs font-medium">
|
||||
@@ -252,7 +266,7 @@ export function ReportOutlineEditor({
|
||||
placeholder={"例如:\n# 引言\n# 一、问题与事实\n## 1.1 范围与依据"}
|
||||
/>
|
||||
<p className="text-[10px] leading-4 text-muted-foreground">
|
||||
选择预设模板会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
|
||||
选择工作流结构会替换下方内容;选择“自定义”会清空后从头编辑。支持 Markdown 标题或编号列表。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -340,6 +354,12 @@ export function ReportConfigCard({
|
||||
availableModes,
|
||||
defaultMode,
|
||||
defaultOptions,
|
||||
/**
|
||||
* A report structure chosen from the collector's workflow configuration
|
||||
* while this card is open. It supersedes only the outline, leaving choices
|
||||
* already made in this card intact.
|
||||
*/
|
||||
outlineOverride,
|
||||
defaultGenerateImages,
|
||||
modelLabel,
|
||||
materialCount,
|
||||
@@ -352,6 +372,7 @@ export function ReportConfigCard({
|
||||
availableModes: ResearchMode[];
|
||||
defaultMode: ResearchMode;
|
||||
defaultOptions: AdvancedResearchOptions;
|
||||
outlineOverride?: string;
|
||||
defaultGenerateImages: boolean;
|
||||
modelLabel: string;
|
||||
/** Number of unique, harvestable materials visible in the collector thread. */
|
||||
@@ -365,10 +386,26 @@ export function ReportConfigCard({
|
||||
const [mode, setMode] = useState<ResearchMode>(
|
||||
availableModes.includes(defaultMode) ? defaultMode : (availableModes[0] ?? "basic"),
|
||||
);
|
||||
const [options, setOptions] = useState<AdvancedResearchOptions>(defaultOptions);
|
||||
const [options, setOptions] = useState<AdvancedResearchOptions>(() => ({
|
||||
...defaultOptions,
|
||||
customOutline: outlineOverride ?? defaultOptions.customOutline,
|
||||
}));
|
||||
const [generateImages, setGenerateImages] = useState(defaultGenerateImages);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
// The collector composer and this confirmation card are both reachable while
|
||||
// a session is awaiting report writing. Selecting a structure in the former
|
||||
// must be reflected here, but should not reset the card's mode, image switch,
|
||||
// or any other in-progress edits.
|
||||
useEffect(() => {
|
||||
if (outlineOverride === undefined) return;
|
||||
setOptions((current) => (
|
||||
current.customOutline === outlineOverride
|
||||
? current
|
||||
: { ...current, customOutline: outlineOverride }
|
||||
));
|
||||
}, [outlineOverride]);
|
||||
|
||||
const modes = useMemo(
|
||||
() => (availableModes.length ? availableModes : (["basic", "quick", "detailed", "deep", "multi_agent"] as ResearchMode[])),
|
||||
[availableModes],
|
||||
|
||||
@@ -5706,6 +5706,10 @@ function RightWorkspace({
|
||||
}
|
||||
skillsTitle={specialAgentConfig ? "技能列表" : undefined}
|
||||
showCommonQuestions={specialAgentConfig}
|
||||
jobResponsibilities={
|
||||
chain?.seats.find((seat) => seat.agent_id === selectedAgentId)
|
||||
?.job_responsibilities ?? ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,11 @@ export interface ChainSeat {
|
||||
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
|
||||
*/
|
||||
position_id?: string | null;
|
||||
/**
|
||||
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
|
||||
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
|
||||
*/
|
||||
job_responsibilities?: string | null;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
|
||||
|
||||
@@ -476,6 +476,19 @@ export default function BusinessChainEditorPage() {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setSeatJobResponsibilitiesCb = useCallback((agentId: string, jobResponsibilities: string) => {
|
||||
setStageGroups((prev) =>
|
||||
prev.map((group) => ({
|
||||
...group,
|
||||
seats: group.seats.map((seat) =>
|
||||
seat.agent_id === agentId
|
||||
? { ...seat, job_responsibilities: jobResponsibilities }
|
||||
: seat,
|
||||
),
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
|
||||
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
|
||||
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
|
||||
@@ -681,6 +694,8 @@ export default function BusinessChainEditorPage() {
|
||||
seat_mode: s.seat_mode ?? null,
|
||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||
position_id: s.position_id ?? null,
|
||||
// 业务链席位岗位职责:会商智能体配置面板只读展示。
|
||||
job_responsibilities: s.job_responsibilities?.trim() || null,
|
||||
};
|
||||
});
|
||||
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
|
||||
@@ -1284,6 +1299,7 @@ export default function BusinessChainEditorPage() {
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={setSeatPositionCb}
|
||||
onSeatJobResponsibilitiesChange={setSeatJobResponsibilitiesCb}
|
||||
onStageGoalChange={setGoal}
|
||||
goalOptimizingIdx={goalOptimizingIdx}
|
||||
onOptimizeGoal={handleOptimizeStageGoal}
|
||||
@@ -1468,6 +1484,7 @@ function StagesColumn({
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onSeatJobResponsibilitiesChange,
|
||||
onStageGoalChange,
|
||||
goalOptimizingIdx,
|
||||
onOptimizeGoal,
|
||||
@@ -1492,6 +1509,7 @@ function StagesColumn({
|
||||
/** Live directory from the administrator's position-role configuration. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
|
||||
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
|
||||
goalOptimizingIdx: number | null;
|
||||
@@ -1601,6 +1619,7 @@ function StagesColumn({
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onSeatPositionChange={onSeatPositionChange}
|
||||
onSeatJobResponsibilitiesChange={onSeatJobResponsibilitiesChange}
|
||||
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||
goalOptimizing={goalOptimizingIdx === stageIdx}
|
||||
anyGoalOptimizing={goalOptimizingIdx !== null}
|
||||
@@ -1636,6 +1655,7 @@ function StageSection({
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
onSeatPositionChange,
|
||||
onSeatJobResponsibilitiesChange,
|
||||
onGoalChange,
|
||||
goalOptimizing,
|
||||
anyGoalOptimizing,
|
||||
@@ -1660,6 +1680,7 @@ function StageSection({
|
||||
positionConfigMode: boolean;
|
||||
positionOptions: readonly PositionOption[];
|
||||
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
|
||||
onSeatJobResponsibilitiesChange: (agentId: string, jobResponsibilities: string) => void;
|
||||
onGoalChange: (goal: string) => void;
|
||||
/** 本 Stage 的阶段目标正在 AI 优化中。 */
|
||||
goalOptimizing: boolean;
|
||||
@@ -1827,6 +1848,9 @@ function StageSection({
|
||||
showPositionConfig={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
|
||||
onJobResponsibilitiesChange={(value) =>
|
||||
onSeatJobResponsibilitiesChange(seat.agent_id, value)
|
||||
}
|
||||
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
|
||||
/>
|
||||
))}
|
||||
@@ -1856,6 +1880,7 @@ function SeatStrip({
|
||||
showPositionConfig,
|
||||
positionOptions,
|
||||
onPositionChange,
|
||||
onJobResponsibilitiesChange,
|
||||
onDetail,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
@@ -1880,6 +1905,7 @@ function SeatStrip({
|
||||
/** Current position directory, injected by the business-chain editor. */
|
||||
positionOptions: readonly PositionOption[];
|
||||
onPositionChange: (positionId: PositionId | null) => void;
|
||||
onJobResponsibilitiesChange: (jobResponsibilities: string) => void;
|
||||
onDetail: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
@@ -1955,6 +1981,17 @@ function SeatStrip({
|
||||
{seat.description}
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-1.5 flex flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">岗位职责</span>
|
||||
<textarea
|
||||
value={seat.job_responsibilities ?? ""}
|
||||
onChange={(event) => onJobResponsibilitiesChange(event.currentTarget.value)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
placeholder="填写该席位在本业务链中的岗位职责"
|
||||
rows={2}
|
||||
className="w-full resize-none rounded border border-border bg-background px-2 py-1 text-[11px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-primary/50"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 岗位协同从专用入口进来时才展示。使用 TDesign Select,未分配不会阻塞普通圆桌使用。 */}
|
||||
|
||||
@@ -302,6 +302,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
|
||||
export default function RoundtablePlanningPage({
|
||||
compactChrome = false,
|
||||
/** 仅供独立业务页临时锁定深色视觉;不修改全局主题状态。 */
|
||||
forceDarkAppearance = false,
|
||||
/** 独立业务页锁定的公共业务链;不影响原多智能体会商入口。 */
|
||||
lockedChainId = null,
|
||||
/** 即使尚未配置业务链,也禁止用户改为推荐/手选链路。 */
|
||||
@@ -345,6 +347,7 @@ export default function RoundtablePlanningPage({
|
||||
taskArtifactSubmissionConfig,
|
||||
}: {
|
||||
compactChrome?: boolean;
|
||||
forceDarkAppearance?: boolean;
|
||||
lockedChainId?: string | null;
|
||||
lockBusinessChain?: boolean;
|
||||
storageTaskId?: string | null;
|
||||
@@ -391,7 +394,9 @@ export default function RoundtablePlanningPage({
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
const { watchJob } = useRoundtableJobNotifier();
|
||||
const isDark = theme === "dark";
|
||||
// 保留 ThemeContext 的主题切换能力;独立业务页可只覆盖自身的视觉模式,
|
||||
// 不触碰全局 html 主题,后续移除该 prop 即可恢复随系统主题切换。
|
||||
const isDark = forceDarkAppearance || theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
@@ -2205,16 +2210,24 @@ export default function RoundtablePlanningPage({
|
||||
期间全屏覆盖,避免线程创建慢时页面像卡住。问答一旦开始或超时即撤掉。 */}
|
||||
{showBootLoading && (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-white"
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex items-center justify-center",
|
||||
isDark ? "bg-[#061b3b]" : "bg-white",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-7 px-8">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-lg font-semibold tracking-wide text-slate-800">
|
||||
<p className={cn(
|
||||
"text-lg font-semibold tracking-wide",
|
||||
isDark ? "text-[#e7f0ff]" : "text-slate-800",
|
||||
)}>
|
||||
正在准备多智能体会商
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">正在初始化任务理解智能体,请稍候…</p>
|
||||
<p className={cn("text-sm", isDark ? "text-[#8aa0c4]" : "text-slate-400")}>
|
||||
正在初始化任务理解智能体,请稍候…
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 animate-bounce rounded-full bg-red-400 [animation-delay:-0.3s]" />
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LlmWikiSearchResult } from "./llmwiki";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/enterprise-research`;
|
||||
|
||||
export type EnterpriseResearchTaskStatus = "draft" | "collecting" | "ready" | "partial" | "failed";
|
||||
|
||||
export interface EnterpriseResearchTask {
|
||||
id: string;
|
||||
subject: string;
|
||||
focus: string;
|
||||
template_id: string;
|
||||
knowledge_base_ids: string[];
|
||||
queries: string[];
|
||||
selected_source_ids: string[];
|
||||
sources: LlmWikiSearchResult[];
|
||||
plan_markdown?: string | null;
|
||||
report_markdown?: string | null;
|
||||
report_summary?: string | null;
|
||||
report_model_name?: string | null;
|
||||
active_report_job_id?: string | null;
|
||||
status: EnterpriseResearchTaskStatus;
|
||||
warning?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export type EnterpriseResearchReportJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
export interface EnterpriseResearchReportJob {
|
||||
id: string;
|
||||
task_id: string;
|
||||
status: EnterpriseResearchReportJobStatus;
|
||||
phase: string;
|
||||
model_name?: string | null;
|
||||
action: "initial" | "rewrite" | "followup";
|
||||
instruction?: string | null;
|
||||
style?: string | null;
|
||||
target_length?: number | null;
|
||||
original_report?: string | null;
|
||||
report_markdown: string;
|
||||
report_summary?: string | null;
|
||||
error_message?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export type EnterpriseResearchReportEvent =
|
||||
| { type: "snapshot"; job: EnterpriseResearchReportJob }
|
||||
| { type: "phase"; phase: string; status: EnterpriseResearchReportJobStatus }
|
||||
| { type: "report_delta"; delta: string }
|
||||
| { type: "reasoning_delta"; delta: string }
|
||||
| { type: "completed" | "failed" | "cancelled"; job: EnterpriseResearchReportJob | null }
|
||||
| { type: "heartbeat"; status: EnterpriseResearchReportJobStatus; phase: string };
|
||||
|
||||
export interface CreateEnterpriseResearchTaskInput {
|
||||
subject: string;
|
||||
focus: string;
|
||||
template_id: string;
|
||||
knowledge_base_ids: string[];
|
||||
queries: string[];
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listEnterpriseResearchTasks(limit = 20): Promise<EnterpriseResearchTask[]> {
|
||||
const payload = await readJson<{ tasks?: EnterpriseResearchTask[] }>(
|
||||
await apiFetch(`${API()}/tasks?limit=${encodeURIComponent(limit)}`),
|
||||
"读取研究任务",
|
||||
);
|
||||
return payload.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function createEnterpriseResearchTask(input: CreateEnterpriseResearchTaskInput): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"创建研究任务",
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectEnterpriseResearchTask(taskId: string): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/collect`, { method: "POST" }),
|
||||
"收集研究证据",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEnterpriseResearchPlanDraft(taskId: string): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/draft`, { method: "POST" }),
|
||||
"生成研究计划",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEnterpriseResearchAiPlan(
|
||||
taskId: string,
|
||||
input: { model_name?: string | null; instruction?: string } = {},
|
||||
): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/ai`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"AI 优化研究计划",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateEnterpriseResearchPlan(
|
||||
taskId: string,
|
||||
input: { plan_markdown: string; selected_source_ids: string[] },
|
||||
): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"保存研究计划",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEnterpriseResearchReportJob(
|
||||
taskId: string,
|
||||
input: {
|
||||
model_name?: string | null;
|
||||
action?: "initial" | "rewrite" | "followup";
|
||||
instruction?: string;
|
||||
style?: "formal" | "analytical" | "concise";
|
||||
target_length?: number | null;
|
||||
} = {},
|
||||
): Promise<EnterpriseResearchReportJob> {
|
||||
return readJson<EnterpriseResearchReportJob>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"创建报告写作任务",
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelEnterpriseResearchReportJob(
|
||||
taskId: string,
|
||||
jobId: string,
|
||||
): Promise<EnterpriseResearchReportJob> {
|
||||
return readJson<EnterpriseResearchReportJob>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"取消报告任务",
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreEnterpriseResearchReportVersion(
|
||||
taskId: string,
|
||||
jobId: string,
|
||||
): Promise<EnterpriseResearchTask> {
|
||||
return readJson<EnterpriseResearchTask>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/restore`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"恢复报告版本",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listEnterpriseResearchReportJobs(taskId: string): Promise<EnterpriseResearchReportJob[]> {
|
||||
const payload = await readJson<{ jobs?: EnterpriseResearchReportJob[] }>(
|
||||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`),
|
||||
"读取报告写作任务",
|
||||
);
|
||||
return payload.jobs ?? [];
|
||||
}
|
||||
|
||||
/** Parse the endpoint's standard SSE frames without introducing an EventSource auth gap. */
|
||||
export async function streamEnterpriseResearchReportJob(
|
||||
taskId: string,
|
||||
jobId: string,
|
||||
handlers: { onEvent: (event: EnterpriseResearchReportEvent) => void; signal?: AbortSignal },
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/stream`,
|
||||
{ headers: { Accept: "text/event-stream" }, signal: handlers.signal },
|
||||
);
|
||||
if (!response.ok || !response.body) {
|
||||
await readJson(response, "连接报告流");
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let pending = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
pending += decoder.decode(value, { stream: true });
|
||||
const frames = pending.split("\n\n");
|
||||
pending = frames.pop() ?? "";
|
||||
for (const frame of frames) {
|
||||
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
handlers.onEvent(JSON.parse(dataLine.slice(6)) as EnterpriseResearchReportEvent);
|
||||
} catch {
|
||||
// One malformed frame must not discard an otherwise healthy stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEnterpriseResearchTask(taskId: string): Promise<void> {
|
||||
const response = await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `删除研究任务失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
}
|
||||
@@ -994,6 +994,10 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
The embedded detail proxy hides WeKnora's outer `.main > .aside_box` application sidebar and expands the route outlet, but deliberately keeps the Wiki page's internal directory/index sidebar.
|
||||
|
||||
**Embedded native knowledge-base switching:** The original WeKnora breadcrumb picker remains available inside the detail iframe. A signed 15-minute embed cookie carries only the current DeerFlow user's visible mappings (their own plus published bases); the proxied `/api/v1/knowledge-bases` response is filtered to that allowlist. Choosing another entry performs a full iframe navigation, rechecks the DeerFlow mapping and read/write permission, then reissues the cookie for the selected base. Never expose the upstream service-admin list directly or permit an arbitrary remote knowledge-base ID.
|
||||
|
||||
The independent enterprise-research workbench (`/api/enterprise-research/tasks`) is deliberately separate from `/api/deep-research`: it stores an owner-scoped task plus a bounded, deduplicated evidence snapshot in `enterprise_research_tasks`, then calls WeKnora through the existing DeerFlow LLMWiki mapping and visibility checks. Never expose remote WeKnora IDs or credentials to its browser API, and do not make this workbench mutate a knowledge base or an existing Deep Research session/report. After collection, its plan endpoints can save an editable Markdown outline and an explicitly selected source subset; validate every selected chunk ID against the saved snapshot. The AI-plan variant may call a configured DeerFlow model but must not retrieve new evidence. `enterprise_research_report_jobs` freezes that plan/source subset plus action/model/style/length and before-image before calling the shared `DeerFlowCompletionBackend`, so the writer cannot run a fresh uncontrolled retrieval or accidentally use later task edits. Actions are `initial`, `rewrite`, and report-only `followup`; only successful initial/rewrite jobs may replace the task report. Its SSE route sends the persisted draft first and then only live `report_delta`/`reasoning_delta` events; reasoning must never be appended to report Markdown. Active rows carry a nullable owner/task dedupe key with a database unique constraint; terminal transitions clear it so history remains unlimited while simultaneous workers cannot create two active jobs for one task. Startup recovery must requeue the exact previously-read running snapshot and then use an atomic queued→running claim. Cancellation, completion, and failure are conditional state transitions so cross-process races cannot turn cancelled work into a committed report. Restore only accepts a completed rewrite before-image, rejects active jobs, and writes a new reversible history row before changing the task. The persistence contract is `deerflow.persistence.enterprise_research`; large evidence/report payloads use `PortableLongText` because MySQL `TEXT` cannot safely hold them. Regression coverage: `tests/test_enterprise_research_tasks.py`.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
`DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes.
|
||||
|
||||
@@ -81,7 +81,9 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **Enterprise Research workbench**: `/api/enterprise-research/tasks` is an independent, user-scoped evidence workbench; it never enters or changes `/api/deep-research`. DeerFlow persists each task's topic, generated queries, selected DeerFlow knowledge-base mapping IDs, collection state, and a bounded/deduplicated WeKnora evidence snapshot. Collection reuses the LLMWiki BFF's mapping-based authorization, so browsers never send a remote WeKnora ID or credential and can retrieve only sources in their own or published knowledge bases. Once collection finishes, the task can persist an editable Markdown research plan and a validated subset of its snapshot sources; the optional AI-plan endpoint improves that outline with a selected DeerFlow model without retrieving new material. Persistent `report-jobs` support initial writing, full-report rewrite, and report-only follow-up. Each job freezes its plan, source subset, model/style/length controls and before-image, streams Markdown/reasoning through DeerFlow's standard model adapter, persists the latest draft for reconnect, and commits only a successful non-follow-up report back to the owning task. Startup recovery uses compare-and-set requeue/claim, while cancellation and completion are conditional transitions so another process cannot overwrite a cancelled job. Markdown/Word delivery, clickable evidence citations, before/after comparison and reversible rewrite versions are served from the same task history without modifying an existing Deep Research report.
|
||||
- **Embedded WeKnora detail chrome**: the proxy hides WeKnora's application sidebar (`.main > .aside_box`) and expands the detail outlet to the full iframe width, while preserving the Wiki page's own index/navigation panel.
|
||||
- **Embedded WeKnora knowledge-base switching**: the original breadcrumb picker works inside the iframe, but lists only the signed-in user's own and published knowledge bases. Every switch is reauthorized through DeerFlow and receives a fresh short-lived iframe session, so it never exposes the upstream service-admin knowledge-base list.
|
||||
- **Prefixed WeKnora deployment**: the frontend may be published under a separate prefix such as `/magentweb`, while embedded WeKnora detail traffic is served by the Gateway under `/deerflow`. The Gateway rewrites HTML root assets, WeKnora API URLs, redirects, and Vite dynamic dependency maps so page assets stay under `/deerflow`; the prefixed proxy routes must be registered before the unprefixed fallback. The signed embed cookie covers `/deerflow`, and auth/CSRF recognition includes all supported static directories.
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
@@ -152,6 +154,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `GET/POST/PUT/DELETE /api/enterprise-research/tasks*` | Independent enterprise-research evidence tasks. The authenticated DeerFlow account owns the task and all saved evidence snapshots; collection calls the configured WeKnora service only after resolving selected mapping IDs through normal visibility and permission checks. `POST /{id}/plan/draft` creates an editable deterministic plan, `POST /{id}/plan/ai` optionally improves it with the configured model, and `PUT /{id}/plan` persists its Markdown plus a source subset revalidated against that task's snapshot. `POST /{id}/report-jobs` freezes those inputs and accepts `initial` / `rewrite` / `followup` plus model, style and length controls; the job list is durable history, its SSE route replays the latest persisted draft before live Markdown/reasoning deltas, `/cancel` safely stops an active job, and `/restore` creates another reversible version while restoring a completed rewrite's before-image. It deliberately cannot change existing Deep Research reports or knowledge bases. |
|
||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
||||
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
||||
|
||||
@@ -41,6 +41,7 @@ from app.gateway.routers import (
|
||||
feedback,
|
||||
dashboard_sessions,
|
||||
deep_research,
|
||||
enterprise_research,
|
||||
html_page_favorites,
|
||||
intent,
|
||||
knowledge,
|
||||
@@ -1560,6 +1561,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Deep Research (深度研究) API at /api/deep-research
|
||||
app.include_router(deep_research.router)
|
||||
|
||||
# Independent enterprise-research evidence workbench at /api/enterprise-research.
|
||||
app.include_router(enterprise_research.router)
|
||||
|
||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||
app.include_router(suggestions.router)
|
||||
|
||||
|
||||
@@ -305,6 +305,31 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.report_structure_store = make_report_structure_store(sf)
|
||||
|
||||
from deerflow.persistence.enterprise_research import (
|
||||
make_enterprise_research_report_job_store,
|
||||
make_enterprise_research_task_store,
|
||||
)
|
||||
|
||||
app.state.enterprise_research_task_store = make_enterprise_research_task_store(sf)
|
||||
app.state.enterprise_research_report_job_store = make_enterprise_research_report_job_store(sf)
|
||||
|
||||
# 企业研究报告写作是独立的、冻结计划/证据快照的后台任务;它不复用或改变
|
||||
# 已有 deep-research 的 Job / dispatcher。
|
||||
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
|
||||
|
||||
app.state.enterprise_research_report_executor = EnterpriseResearchReportExecutor(
|
||||
app.state.enterprise_research_task_store,
|
||||
app.state.enterprise_research_report_job_store,
|
||||
)
|
||||
|
||||
async def _recover_enterprise_research_reports() -> None:
|
||||
try:
|
||||
await app.state.enterprise_research_report_executor.recover_pending()
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("[enterprise-research] startup report recovery failed", exc_info=True)
|
||||
|
||||
asyncio.create_task(_recover_enterprise_research_reports())
|
||||
|
||||
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
||||
from deerflow.persistence.llmwiki import make_llmwiki_store
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Independent, snapshot-based report writer for the enterprise workbench.
|
||||
|
||||
This deliberately does *not* use the existing deep-research job executor. It
|
||||
only consumes a task's persisted plan and selected WeKnora evidence snapshot,
|
||||
then streams Markdown through the shared DeerFlow model adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.deep_research.adapters.llm import DeerFlowCompletionBackend
|
||||
from deerflow.agents.deep_research.config import DeepResearchConfig
|
||||
from deerflow.persistence.enterprise_research import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TERMINAL = {"completed", "failed", "cancelled"}
|
||||
|
||||
|
||||
class EnterpriseResearchLiveHub:
|
||||
"""Small in-process fan-out for live deltas; completed output stays in SQL."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._queues: dict[str, set[asyncio.Queue[dict[str, Any]]]] = defaultdict(set)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(
|
||||
self, job_id: str
|
||||
) -> tuple[asyncio.Queue[dict[str, Any]], Callable[[], Awaitable[None]]]:
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
||||
async with self._lock:
|
||||
self._queues[job_id].add(queue)
|
||||
|
||||
async def unsubscribe() -> None:
|
||||
async with self._lock:
|
||||
queues = self._queues.get(job_id)
|
||||
if queues is not None:
|
||||
queues.discard(queue)
|
||||
if not queues:
|
||||
self._queues.pop(job_id, None)
|
||||
|
||||
return queue, unsubscribe
|
||||
|
||||
async def publish(self, job_id: str, event: dict[str, Any]) -> None:
|
||||
async with self._lock:
|
||||
queues = list(self._queues.get(job_id, ()))
|
||||
for queue in queues:
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# A slow browser can reconnect and receive the persisted draft;
|
||||
# never let it stall model streaming for every other client.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(event)
|
||||
except (asyncio.QueueEmpty, asyncio.QueueFull):
|
||||
pass
|
||||
|
||||
|
||||
class EnterpriseResearchReportExecutor:
|
||||
def __init__(self, task_store: EnterpriseResearchTaskStore, job_store: EnterpriseResearchReportJobStore) -> None:
|
||||
self._tasks = task_store
|
||||
self._jobs = job_store
|
||||
self._hub = EnterpriseResearchLiveHub()
|
||||
self._running: dict[str, asyncio.Task[None]] = {}
|
||||
self._guard = asyncio.Lock()
|
||||
|
||||
async def ensure_started(self, job_id: str, user_id: str) -> None:
|
||||
"""Start exactly one in-process runner for an owner-scoped persisted job."""
|
||||
|
||||
async with self._guard:
|
||||
existing = self._running.get(job_id)
|
||||
if existing is not None and not existing.done():
|
||||
return
|
||||
task = asyncio.create_task(self._run(job_id, user_id), name=f"enterprise-research-report:{job_id}")
|
||||
self._running[job_id] = task
|
||||
task.add_done_callback(lambda _: self._running.pop(job_id, None))
|
||||
|
||||
async def recover_pending(self) -> None:
|
||||
"""Requeue process-orphaned jobs and let the normal atomic claim decide the runner."""
|
||||
|
||||
for job in await self._jobs.list_recoverable(limit=100):
|
||||
if job["status"] == "running":
|
||||
requeued = await self._jobs.requeue_job(
|
||||
job["id"], job["user_id"], expected_updated_at=job.get("updated_at")
|
||||
)
|
||||
if not requeued:
|
||||
# Another process already recovered or resumed this exact
|
||||
# snapshot; its normal claim now owns the execution.
|
||||
continue
|
||||
await self.ensure_started(job["id"], job["user_id"])
|
||||
|
||||
async def cancel(self, job_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
job = await self._jobs.get_job(job_id, user_id)
|
||||
if job is None or job["status"] in _TERMINAL:
|
||||
return job
|
||||
running = self._running.get(job_id)
|
||||
if running is not None and not running.done():
|
||||
running.cancel()
|
||||
try:
|
||||
await running
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return await self._jobs.get_job(job_id, user_id)
|
||||
cancelled = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"queued", "running"},
|
||||
data={"status": "cancelled", "phase": "cancelled"},
|
||||
)
|
||||
if cancelled is not None:
|
||||
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
|
||||
await self._hub.publish(job_id, {"type": "cancelled", "job": cancelled})
|
||||
return cancelled
|
||||
return await self._jobs.get_job(job_id, user_id)
|
||||
|
||||
async def stream_events(self, job_id: str, user_id: str) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Replay the latest durable draft, then attach to live token events."""
|
||||
|
||||
queue, unsubscribe = await self._hub.subscribe(job_id)
|
||||
try:
|
||||
current = await self._jobs.get_job(job_id, user_id)
|
||||
if current is None:
|
||||
return
|
||||
yield {"type": "snapshot", "job": current}
|
||||
if current["status"] in _TERMINAL:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except TimeoutError:
|
||||
current = await self._jobs.get_job(job_id, user_id)
|
||||
if current is None:
|
||||
return
|
||||
yield {"type": "heartbeat", "status": current["status"], "phase": current["phase"]}
|
||||
if current["status"] in _TERMINAL:
|
||||
return
|
||||
continue
|
||||
yield event
|
||||
if event.get("type") in {"completed", "failed", "cancelled"}:
|
||||
return
|
||||
finally:
|
||||
await unsubscribe()
|
||||
|
||||
async def _set_phase(self, job_id: str, user_id: str, phase: str, **fields: Any) -> dict[str, Any] | None:
|
||||
row = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"running"}, data={"phase": phase, **fields}
|
||||
)
|
||||
if row is not None:
|
||||
await self._hub.publish(job_id, {"type": "phase", "phase": phase, "status": row["status"]})
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _messages(job: dict[str, Any]) -> list[dict[str, str]]:
|
||||
sources = job.get("sources") or []
|
||||
evidence: list[str] = []
|
||||
for index, source in enumerate(sources[:24], start=1):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
chunk_id = str(source.get("chunk_id") or f"source-{index}")
|
||||
title = str(source.get("title") or source.get("filename") or "未命名资料")
|
||||
content = str(source.get("content") or "").strip()[:2_500]
|
||||
if content:
|
||||
evidence.append(f"[资料 {index} | id={chunk_id} | {title}]\n{content}")
|
||||
action = str(job.get("action") or "initial")
|
||||
style = str(job.get("style") or "formal")
|
||||
instruction = str(job.get("instruction") or "").strip()
|
||||
target_length = int(job.get("target_length") or 0)
|
||||
source_context = f"# 已确认资料(共 {len(evidence)} 条)\n" + "\n\n".join(evidence)
|
||||
common_system = (
|
||||
"你是一名严谨的企业研究报告撰写人。只可使用用户确认的研究计划、现有报告和资料快照;"
|
||||
"不可联网、不可补充未在资料中出现的具体事实或数据。不要输出 <think> 标签。每个事实性判断"
|
||||
"尽量以 [资料:id] 标识其对应资料;证据不足时明确写为待验证判断。"
|
||||
)
|
||||
if action == "followup":
|
||||
return [
|
||||
{"role": "system", "content": common_system + "请直接回答报告追问,不要重写整篇报告。"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"# 当前报告\n{job.get('original_report') or ''}\n\n{source_context}\n\n"
|
||||
f"# 用户问题\n{instruction}"
|
||||
),
|
||||
},
|
||||
]
|
||||
if action == "rewrite":
|
||||
length_hint = f"目标篇幅约 {target_length} 字。" if target_length else "篇幅与原文大体相当。"
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": common_system + "输出仅为改写后的完整 Markdown 报告,不要解释改写过程。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"请全文改写下列企业研究报告。写作风格:{style};{length_hint}\n"
|
||||
f"改写要求:{instruction or '优化结构、逻辑与表达,并保留事实和引用。'}\n\n"
|
||||
f"# 研究计划\n{job['plan_snapshot']}\n\n"
|
||||
f"# 原报告\n{job.get('original_report') or ''}\n\n{source_context}"
|
||||
),
|
||||
},
|
||||
]
|
||||
length_hint = f"报告目标篇幅约 {target_length} 字。" if target_length else ""
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": common_system + "输出仅为完整 Markdown 报告,不要解释写作过程。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"请按下列研究计划撰写企业研究报告。保留计划中有意义的章节层级,"
|
||||
f"内容应连贯、可核验,结论与建议要说明证据边界。写作风格:{style}。{length_hint}\n\n"
|
||||
f"# 研究计划\n{job['plan_snapshot']}\n\n"
|
||||
+ source_context
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _summary(
|
||||
report: str,
|
||||
source_count: int,
|
||||
action: str,
|
||||
*,
|
||||
original_report: str = "",
|
||||
instruction: str = "",
|
||||
) -> str:
|
||||
headings = sum(1 for line in report.splitlines() if line.lstrip().startswith("#"))
|
||||
if action == "followup":
|
||||
return f"已基于当前报告和 {source_count} 条已确认资料完成追问回答,共 {len(report)} 字。"
|
||||
if action == "rewrite":
|
||||
before_blocks = [block.strip() for block in original_report.split("\n\n") if block.strip()]
|
||||
after_blocks = [block.strip() for block in report.split("\n\n") if block.strip()]
|
||||
matcher = SequenceMatcher(a=before_blocks, b=after_blocks, autojunk=False)
|
||||
changed_blocks = sum(
|
||||
max(before_end - before_start, after_end - after_start)
|
||||
for tag, before_start, before_end, after_start, after_end in matcher.get_opcodes()
|
||||
if tag != "equal"
|
||||
)
|
||||
before_headings = sum(1 for line in original_report.splitlines() if line.lstrip().startswith("#"))
|
||||
request = instruction.strip()[:120] or "优化结构、逻辑与表达,并保留事实和引用"
|
||||
return (
|
||||
f"已依据 {source_count} 条已确认资料完成全文改写:原文 {len(original_report)} 字,"
|
||||
f"改写后 {len(report)} 字,约调整 {changed_blocks} 个内容块,标题节点 "
|
||||
f"{before_headings}→{headings}。改写要求:{request}。"
|
||||
)
|
||||
return f"已依据 {source_count} 条已确认资料完成报告,共 {len(report)} 字,包含 {headings} 个标题层级节点。"
|
||||
|
||||
async def _run(self, job_id: str, user_id: str) -> None:
|
||||
job = await self._jobs.get_job(job_id, user_id)
|
||||
if job is None or job["status"] in _TERMINAL:
|
||||
return
|
||||
if not await self._jobs.claim_job(job_id, user_id):
|
||||
return
|
||||
if await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"running"},
|
||||
data={"phase": "understanding", "error_message": None, "report_markdown": ""},
|
||||
) is None:
|
||||
return
|
||||
await self._hub.publish(job_id, {"type": "phase", "phase": "understanding", "status": "running"})
|
||||
report_parts: list[str] = []
|
||||
last_persist = 0.0
|
||||
try:
|
||||
await asyncio.sleep(0) # give the client an observable understanding phase
|
||||
if await self._set_phase(job_id, user_id, "writing") is None:
|
||||
return
|
||||
config = DeepResearchConfig(
|
||||
mode="basic", language="zh-CN", smart_model=job.get("model_name") or None,
|
||||
custom_outline=str(job["plan_snapshot"])[:4_000], max_context_words=24_000,
|
||||
).clamp()
|
||||
backend = DeerFlowCompletionBackend(config)
|
||||
|
||||
async def on_delta(delta: str) -> None:
|
||||
nonlocal last_persist
|
||||
report_parts.append(delta)
|
||||
await self._hub.publish(job_id, {"type": "report_delta", "delta": delta})
|
||||
now = time.monotonic()
|
||||
if now - last_persist >= 1.25:
|
||||
last_persist = now
|
||||
persisted = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"running"},
|
||||
data={"report_markdown": "".join(report_parts)},
|
||||
)
|
||||
if persisted is None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def on_reasoning(delta: str) -> None:
|
||||
# Thought traces are deliberately separate from Markdown. The
|
||||
# client may choose to show them in a collapsed activity item.
|
||||
await self._hub.publish(job_id, {"type": "reasoning_delta", "delta": delta})
|
||||
|
||||
result = await backend.stream_complete(
|
||||
model_role="smart", messages=self._messages(job), operation="enterprise_research_write_report",
|
||||
on_delta=on_delta, on_reasoning=on_reasoning,
|
||||
)
|
||||
report = result.text.strip()
|
||||
if not report:
|
||||
raise RuntimeError("模型没有返回可写入的报告内容")
|
||||
if await self._set_phase(job_id, user_id, "reviewing") is None:
|
||||
return
|
||||
action = str(job.get("action") or "initial")
|
||||
summary = self._summary(
|
||||
report,
|
||||
len(job.get("sources") or []),
|
||||
action,
|
||||
original_report=str(job.get("original_report") or ""),
|
||||
instruction=str(job.get("instruction") or ""),
|
||||
)
|
||||
completed = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"running"},
|
||||
data={"status": "completed", "phase": "completed", "report_markdown": report, "report_summary": summary, "model_name": result.model},
|
||||
)
|
||||
if completed is None:
|
||||
# A different process accepted cancellation while this worker
|
||||
# was between its last token and commit. Never overwrite it.
|
||||
return
|
||||
task_update: dict[str, Any] = {"active_report_job_id": None}
|
||||
if action != "followup":
|
||||
task_update.update(
|
||||
{"report_markdown": report, "report_summary": summary, "report_model_name": result.model}
|
||||
)
|
||||
await self._tasks.update_task(job["task_id"], user_id, task_update)
|
||||
await self._hub.publish(job_id, {"type": "completed", "job": completed})
|
||||
except asyncio.CancelledError:
|
||||
cancelled = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"queued", "running"},
|
||||
data={"status": "cancelled", "phase": "cancelled"},
|
||||
)
|
||||
if cancelled is not None:
|
||||
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
|
||||
await self._hub.publish(job_id, {"type": "cancelled", "job": cancelled})
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - turn model/provider failures into a visible job state
|
||||
logger.exception("enterprise research report job %s failed", job_id)
|
||||
message = str(exc)[:2_000] or "报告生成失败,请稍后重试。"
|
||||
failed = await self._jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"running"},
|
||||
data={"status": "failed", "phase": "failed", "report_markdown": "".join(report_parts), "error_message": message},
|
||||
)
|
||||
if failed is not None:
|
||||
await self._tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
|
||||
await self._hub.publish(job_id, {"type": "failed", "job": failed})
|
||||
@@ -69,11 +69,6 @@ class AgentResponse(BaseModel):
|
||||
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
|
||||
)
|
||||
|
||||
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.",
|
||||
@@ -131,7 +126,6 @@ class AgentCreateRequest(BaseModel):
|
||||
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 (默认).")
|
||||
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,
|
||||
@@ -154,7 +148,6 @@ class AgentUpdateRequest(BaseModel):
|
||||
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).")
|
||||
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,
|
||||
@@ -294,7 +287,6 @@ def _write_agent_config(
|
||||
common_questions: list[CommonQuestion] | None = None,
|
||||
template_name: str | 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)
|
||||
@@ -327,8 +319,6 @@ def _write_agent_config(
|
||||
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
||||
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())
|
||||
@@ -346,7 +336,6 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
try:
|
||||
@@ -362,7 +351,6 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
||||
"common_questions": cfg.common_questions,
|
||||
"template_name": cfg.template_name,
|
||||
"seat_skill_directive": cfg.seat_skill_directive,
|
||||
"job_responsibilities": cfg.job_responsibilities,
|
||||
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
|
||||
}
|
||||
|
||||
@@ -531,7 +519,6 @@ def _record_to_response(
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
if include_extras:
|
||||
@@ -558,7 +545,6 @@ def _record_to_response(
|
||||
common_questions=extras.get("common_questions"),
|
||||
template_name=extras.get("template_name"),
|
||||
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"),
|
||||
)
|
||||
@@ -891,7 +877,6 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
common_questions=request.common_questions,
|
||||
template_name=request.template_name,
|
||||
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)
|
||||
@@ -974,14 +959,13 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
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_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"))
|
||||
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"))
|
||||
if config_dirty:
|
||||
_write_agent_config(
|
||||
agent_id,
|
||||
@@ -993,7 +977,6 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
common_questions=next_common_questions,
|
||||
template_name=next_template_name,
|
||||
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
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
"""Independent enterprise-research workbench API.
|
||||
|
||||
This router intentionally does not alter the established ``/api/deep-research``
|
||||
workflow. It stores a small owner-scoped research task and calls WeKnora through
|
||||
the same DeerFlow-owned authorization mapping used by the knowledge-base page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from deerflow.agents.deep_research.adapters.llm import DeerFlowCompletionBackend
|
||||
from deerflow.agents.deep_research.config import DeepResearchConfig
|
||||
from deerflow.integrations.weknora.client import WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.enterprise_research import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/enterprise-research", tags=["enterprise-research"])
|
||||
|
||||
TaskStatus = Literal["draft", "collecting", "ready", "partial", "failed"]
|
||||
|
||||
|
||||
class EnterpriseResearchEvidence(BaseModel):
|
||||
chunk_id: str = Field(default="", max_length=256)
|
||||
content: str = Field(default="", max_length=4_000)
|
||||
knowledge_id: str = Field(default="", max_length=256)
|
||||
knowledge_base_id: str = Field(default="", max_length=256)
|
||||
knowledge_base_name: str = Field(default="", max_length=512)
|
||||
title: str = Field(default="", max_length=1_024)
|
||||
filename: str = Field(default="", max_length=1_024)
|
||||
score: float = 0.0
|
||||
chunk_index: int = 0
|
||||
url: str = Field(default="", max_length=4_096)
|
||||
|
||||
|
||||
class EnterpriseResearchTaskResponse(BaseModel):
|
||||
id: str
|
||||
subject: str
|
||||
focus: str
|
||||
template_id: str
|
||||
knowledge_base_ids: list[str]
|
||||
queries: list[str]
|
||||
selected_source_ids: list[str] = Field(default_factory=list)
|
||||
sources: list[EnterpriseResearchEvidence]
|
||||
plan_markdown: str | None = None
|
||||
report_markdown: str | None = None
|
||||
report_summary: str | None = None
|
||||
report_model_name: str | None = None
|
||||
active_report_job_id: str | None = None
|
||||
status: TaskStatus
|
||||
warning: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class EnterpriseResearchTaskListResponse(BaseModel):
|
||||
tasks: list[EnterpriseResearchTaskResponse]
|
||||
|
||||
|
||||
class EnterpriseResearchTaskCreate(BaseModel):
|
||||
subject: str = Field(..., min_length=1, max_length=180)
|
||||
focus: str = Field(default="", max_length=800)
|
||||
template_id: str = Field(default="topic", min_length=1, max_length=64)
|
||||
knowledge_base_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||
queries: list[str] = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class EnterpriseResearchPlanUpdate(BaseModel):
|
||||
plan_markdown: str = Field(..., min_length=1, max_length=16_000)
|
||||
selected_source_ids: list[str] = Field(default_factory=list, max_length=48)
|
||||
|
||||
|
||||
class EnterpriseResearchAiPlanCreate(BaseModel):
|
||||
model_name: str | None = Field(default=None, max_length=256)
|
||||
instruction: str = Field(default="", max_length=2_000)
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobCreate(BaseModel):
|
||||
model_name: str | None = Field(default=None, max_length=256)
|
||||
action: Literal["initial", "rewrite", "followup"] = "initial"
|
||||
instruction: str = Field(default="", max_length=4_000)
|
||||
style: Literal["formal", "analytical", "concise"] = "formal"
|
||||
target_length: int | None = Field(default=None, ge=800, le=30_000)
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobResponse(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
status: Literal["queued", "running", "completed", "failed", "cancelled"]
|
||||
phase: str
|
||||
model_name: str | None = None
|
||||
action: Literal["initial", "rewrite", "followup"] = "initial"
|
||||
instruction: str | None = None
|
||||
style: str | None = None
|
||||
target_length: int | None = None
|
||||
original_report: str | None = None
|
||||
report_markdown: str = ""
|
||||
report_summary: str | None = None
|
||||
error_message: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobListResponse(BaseModel):
|
||||
jobs: list[EnterpriseResearchReportJobResponse]
|
||||
|
||||
|
||||
def _task_store(request: Request) -> EnterpriseResearchTaskStore:
|
||||
store = getattr(request.app.state, "enterprise_research_task_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="企业深度研究任务存储暂不可用")
|
||||
return store
|
||||
|
||||
|
||||
def _report_job_store(request: Request) -> EnterpriseResearchReportJobStore:
|
||||
store = getattr(request.app.state, "enterprise_research_report_job_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="企业研究报告任务存储暂不可用")
|
||||
return store
|
||||
|
||||
|
||||
def _report_executor(request: Request):
|
||||
executor = getattr(request.app.state, "enterprise_research_report_executor", None)
|
||||
if executor is None:
|
||||
raise HTTPException(status_code=503, detail="企业研究报告执行器暂不可用")
|
||||
return executor
|
||||
|
||||
|
||||
def _report_start_lock(request: Request) -> asyncio.Lock:
|
||||
lock = getattr(request.app.state, "enterprise_research_report_start_lock", None)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
request.app.state.enterprise_research_report_start_lock = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _llmwiki_store(request: Request) -> LlmWikiStore:
|
||||
store = getattr(request.app.state, "llmwiki_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="知识库权限存储暂不可用")
|
||||
return store
|
||||
|
||||
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return "default", True
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _response(row: dict[str, Any]) -> EnterpriseResearchTaskResponse:
|
||||
return EnterpriseResearchTaskResponse(
|
||||
id=str(row["id"]),
|
||||
subject=str(row.get("subject") or ""),
|
||||
focus=str(row.get("focus") or ""),
|
||||
template_id=str(row.get("template_id") or "topic"),
|
||||
knowledge_base_ids=[str(item) for item in row.get("knowledge_base_ids") or []],
|
||||
queries=[str(item) for item in row.get("queries") or []],
|
||||
selected_source_ids=[str(item) for item in row.get("selected_source_ids") or []],
|
||||
sources=[EnterpriseResearchEvidence.model_validate(item) for item in row.get("sources") or [] if isinstance(item, dict)],
|
||||
plan_markdown=str(row["plan_markdown"]) if row.get("plan_markdown") else None,
|
||||
report_markdown=str(row["report_markdown"]) if row.get("report_markdown") else None,
|
||||
report_summary=str(row["report_summary"]) if row.get("report_summary") else None,
|
||||
report_model_name=str(row["report_model_name"]) if row.get("report_model_name") else None,
|
||||
active_report_job_id=str(row["active_report_job_id"]) if row.get("active_report_job_id") else None,
|
||||
status=row.get("status") if row.get("status") in {"draft", "collecting", "ready", "partial", "failed"} else "failed",
|
||||
warning=str(row["warning"]) if row.get("warning") else None,
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
def _report_job_response(row: dict[str, Any]) -> EnterpriseResearchReportJobResponse:
|
||||
job_status = str(row.get("status") or "failed")
|
||||
if job_status not in {"queued", "running", "completed", "failed", "cancelled"}:
|
||||
job_status = "failed"
|
||||
action = str(row.get("action") or "initial")
|
||||
if action not in {"initial", "rewrite", "followup"}:
|
||||
action = "initial"
|
||||
return EnterpriseResearchReportJobResponse(
|
||||
id=str(row["id"]), task_id=str(row["task_id"]), status=job_status, phase=str(row.get("phase") or job_status),
|
||||
model_name=str(row["model_name"]) if row.get("model_name") else None,
|
||||
action=action,
|
||||
instruction=str(row["instruction"]) if row.get("instruction") else None,
|
||||
style=str(row["style"]) if row.get("style") else None,
|
||||
target_length=int(row["target_length"]) if row.get("target_length") else None,
|
||||
original_report=str(row["original_report"]) if row.get("original_report") else None,
|
||||
report_markdown=str(row.get("report_markdown") or ""),
|
||||
report_summary=str(row["report_summary"]) if row.get("report_summary") else None,
|
||||
error_message=str(row["error_message"]) if row.get("error_message") else None,
|
||||
created_at=row.get("created_at"), updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
|
||||
def _normalise_ids(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
item = str(value).strip()
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result[:100]
|
||||
|
||||
|
||||
def _normalise_queries(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
item = str(value).strip()[:512]
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result[:8]
|
||||
|
||||
|
||||
def _default_plan(task: dict[str, Any], selected_source_ids: list[str]) -> str:
|
||||
"""Build a transparent editable plan; report prose is a later, separate job."""
|
||||
|
||||
subject = str(task.get("subject") or "研究主题")
|
||||
focus = str(task.get("focus") or "")
|
||||
template_id = str(task.get("template_id") or "topic")
|
||||
if template_id == "event":
|
||||
chapters = [
|
||||
"一、事件边界与时间线",
|
||||
"二、相关主体与立场",
|
||||
"三、影响传导与风险研判",
|
||||
"四、后续观察与应对建议",
|
||||
]
|
||||
elif template_id == "policy":
|
||||
chapters = [
|
||||
"一、政策与行业背景",
|
||||
"二、现状、数据与变化趋势",
|
||||
"三、传导机制与关键影响",
|
||||
"四、风险、机会与行动建议",
|
||||
]
|
||||
else:
|
||||
chapters = [
|
||||
"一、研究对象与事实背景",
|
||||
"二、关键问题、观点与分歧",
|
||||
"三、影响、风险与机会",
|
||||
"四、结论、待验证问题与建议",
|
||||
]
|
||||
sources_by_id = {str(item.get("chunk_id") or ""): item for item in task.get("sources") or [] if isinstance(item, dict)}
|
||||
selected_titles = [
|
||||
str(sources_by_id[item].get("title") or sources_by_id[item].get("filename") or "未命名资料")
|
||||
for item in selected_source_ids
|
||||
if item in sources_by_id
|
||||
]
|
||||
source_lines = "\n".join(f"- {title}" for title in selected_titles[:12]) or "- 暂无已选资料"
|
||||
scope = focus or "围绕主题的事实、影响与可验证判断"
|
||||
return (
|
||||
f"# {subject}研究计划\n\n"
|
||||
"## 研究目标\n"
|
||||
f"围绕“{subject}”形成可追溯、可核验的研究结论;重点范围:{scope}。\n\n"
|
||||
"## 核心问题\n"
|
||||
"1. 当前已知事实、背景和时间范围是什么?\n"
|
||||
f"2. 与“{subject}”相关的主要主体、观点或变化是什么?\n"
|
||||
"3. 证据能够支持哪些影响、风险、机会或待验证判断?\n\n"
|
||||
"## 报告结构\n"
|
||||
+ "\n".join(f"{chapter}\n- 说明本章需回答的核心问题,并在正文中标记资料依据。" for chapter in chapters)
|
||||
+ "\n\n## 已选证据\n"
|
||||
+ source_lines
|
||||
+ "\n\n## 写作约束\n"
|
||||
"- 仅使用已选资料作为事实依据;不补充未经验证的外部事实。\n"
|
||||
"- 对资料不足或观点相互矛盾之处明确标记,避免将推测写成结论。\n"
|
||||
"- 生成报告前可继续编辑本计划与已选证据。\n"
|
||||
)
|
||||
|
||||
|
||||
def _validate_selected_source_ids(task: dict[str, Any], values: list[str]) -> list[str]:
|
||||
available_ids = {str(item.get("chunk_id") or "") for item in task.get("sources") or [] if isinstance(item, dict)}
|
||||
selected_ids = _normalise_ids(values)[:48]
|
||||
if any(item not in available_ids for item in selected_ids):
|
||||
raise HTTPException(status_code=422, detail="选中的资料已变化,请刷新后重新选择")
|
||||
return selected_ids
|
||||
|
||||
|
||||
async def _visible_knowledge_bases(request: Request, task: dict[str, Any], user_id: str, is_admin: bool) -> list[dict[str, Any]]:
|
||||
store = _llmwiki_store(request)
|
||||
selected_ids = _normalise_ids(task.get("knowledge_base_ids") or [])
|
||||
if not selected_ids:
|
||||
return await store.list_visible(user_id, scope="all", is_admin=False)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||
if row is None:
|
||||
# Never reveal whether this belongs to a different account.
|
||||
raise HTTPException(status_code=404, detail="知识库不存在或无访问权限")
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _client(request: Request):
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||||
if not runtime.weknora_enabled:
|
||||
raise HTTPException(status_code=409, detail="WeKnora 知识服务尚未启用")
|
||||
try:
|
||||
return build_weknora_client(runtime)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _evidence_view(result: dict[str, Any], mappings: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
|
||||
remote_base_id = str(result.get("knowledge_base_id") or "")
|
||||
mapping = mappings.get(remote_base_id)
|
||||
if mapping is None:
|
||||
return None
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": result.get("chunk_id") or ""})
|
||||
return {
|
||||
"chunk_id": str(result.get("chunk_id") or ""),
|
||||
"content": str(result.get("content") or "")[:4_000],
|
||||
"knowledge_id": str(result.get("knowledge_id") or ""),
|
||||
"knowledge_base_id": str(mapping["id"]),
|
||||
"knowledge_base_name": str(mapping.get("name") or ""),
|
||||
"title": str(result.get("title") or ""),
|
||||
"filename": str(result.get("filename") or ""),
|
||||
"score": float(result.get("score") or 0),
|
||||
"chunk_index": int(result.get("chunk_index") or 0),
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
}
|
||||
|
||||
|
||||
def _dedupe_evidence(results: list[dict[str, Any]], mappings: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[str] = set()
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
item = _evidence_view(result, mappings)
|
||||
if item is None:
|
||||
continue
|
||||
key = item["chunk_id"] or f"{item['knowledge_id']}:{item['chunk_index']}:{item['title']}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
evidence.append(item)
|
||||
if len(evidence) >= 48:
|
||||
break
|
||||
return evidence
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=EnterpriseResearchTaskListResponse)
|
||||
async def list_tasks(request: Request, limit: int = Query(default=20, ge=1, le=50)) -> EnterpriseResearchTaskListResponse:
|
||||
user_id, _ = await _actor(request)
|
||||
rows = await _task_store(request).list_tasks(user_id, limit=limit)
|
||||
return EnterpriseResearchTaskListResponse(tasks=[_response(row) for row in rows])
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=EnterpriseResearchTaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(request: Request, body: EnterpriseResearchTaskCreate) -> EnterpriseResearchTaskResponse:
|
||||
user_id, _ = await _actor(request)
|
||||
queries = _normalise_queries(body.queries)
|
||||
if not queries:
|
||||
raise HTTPException(status_code=422, detail="至少需要一个有效检索词")
|
||||
row = await _task_store(request).create_task(
|
||||
{
|
||||
"id": f"ert_{uuid4().hex}",
|
||||
"user_id": user_id,
|
||||
"subject": body.subject.strip(),
|
||||
"focus": body.focus.strip(),
|
||||
"template_id": body.template_id.strip(),
|
||||
"knowledge_base_ids": _normalise_ids(body.knowledge_base_ids),
|
||||
"queries": queries,
|
||||
"status": "draft",
|
||||
}
|
||||
)
|
||||
return _response(row)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/collect", response_model=EnterpriseResearchTaskResponse)
|
||||
async def collect_task(request: Request, task_id: str) -> EnterpriseResearchTaskResponse:
|
||||
"""Collect a bounded, user-authorized WeKnora evidence snapshot for a task."""
|
||||
|
||||
user_id, is_admin = await _actor(request)
|
||||
task_store = _task_store(request)
|
||||
task = await task_store.get_task(task_id, user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
task = await task_store.update_task(task_id, user_id, {"status": "collecting", "warning": None})
|
||||
assert task is not None
|
||||
|
||||
try:
|
||||
bases = await _visible_knowledge_bases(request, task, user_id, is_admin)
|
||||
if not bases:
|
||||
row = await task_store.update_task(task_id, user_id, {"status": "failed", "warning": "当前账号没有可用知识库。"})
|
||||
return _response(row or task)
|
||||
client = _client(request)
|
||||
remote_ids = [str(row["weknora_id"]) for row in bases]
|
||||
outcomes = await asyncio.gather(
|
||||
*(client.search(query, remote_ids) for query in task["queries"]),
|
||||
return_exceptions=True,
|
||||
)
|
||||
raw_results: list[dict[str, Any]] = []
|
||||
failed_queries = 0
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, Exception):
|
||||
failed_queries += 1
|
||||
logger.warning("Enterprise research query failed: %s", outcome)
|
||||
else:
|
||||
raw_results.extend(outcome)
|
||||
sources = _dedupe_evidence(raw_results, {str(row["weknora_id"]): row for row in bases})
|
||||
if not sources:
|
||||
task_status: TaskStatus = "failed"
|
||||
warning = "未检索到可用证据,请调整研究主题或检查知识库解析状态。"
|
||||
elif failed_queries:
|
||||
task_status = "partial"
|
||||
warning = f"{failed_queries} 个检索方向暂时不可用,已保留其余结果。"
|
||||
else:
|
||||
task_status = "ready"
|
||||
warning = None
|
||||
row = await task_store.update_task(task_id, user_id, {"status": task_status, "sources": sources, "warning": warning})
|
||||
return _response(row or task)
|
||||
except HTTPException:
|
||||
raise
|
||||
except WeKnoraError as exc:
|
||||
await task_store.update_task(task_id, user_id, {"status": "failed", "warning": str(exc)})
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None
|
||||
except Exception:
|
||||
logger.exception("Enterprise research collection failed for %s", task_id)
|
||||
await task_store.update_task(task_id, user_id, {"status": "failed", "warning": "证据收集出现异常,请稍后重试。"})
|
||||
raise HTTPException(status_code=502, detail="证据收集失败,请稍后重试") from None
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/plan/draft", response_model=EnterpriseResearchTaskResponse)
|
||||
async def create_plan_draft(request: Request, task_id: str) -> EnterpriseResearchTaskResponse:
|
||||
"""Create a deterministic, editable research-plan draft from persisted evidence."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
task_store = _task_store(request)
|
||||
task = await task_store.get_task(task_id, user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
if not task.get("sources"):
|
||||
raise HTTPException(status_code=409, detail="请先完成证据收集,再生成研究计划")
|
||||
selected_source_ids = _validate_selected_source_ids(task, [
|
||||
str(item.get("chunk_id") or "") for item in task["sources"][:24] if isinstance(item, dict)
|
||||
])
|
||||
row = await task_store.update_task(
|
||||
task_id,
|
||||
user_id,
|
||||
{
|
||||
"selected_source_ids": selected_source_ids,
|
||||
"plan_markdown": _default_plan(task, selected_source_ids),
|
||||
},
|
||||
)
|
||||
return _response(row or task)
|
||||
|
||||
|
||||
@router.put("/tasks/{task_id}/plan", response_model=EnterpriseResearchTaskResponse)
|
||||
async def update_plan(request: Request, task_id: str, body: EnterpriseResearchPlanUpdate) -> EnterpriseResearchTaskResponse:
|
||||
"""Persist the user's selected evidence and editable plan before report generation."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
task_store = _task_store(request)
|
||||
task = await task_store.get_task(task_id, user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
if not task.get("sources"):
|
||||
raise HTTPException(status_code=409, detail="当前任务没有可用于编排的证据")
|
||||
selected_source_ids = _validate_selected_source_ids(task, body.selected_source_ids)
|
||||
plan_markdown = body.plan_markdown.strip()
|
||||
if not plan_markdown:
|
||||
raise HTTPException(status_code=422, detail="研究计划不能为空")
|
||||
row = await task_store.update_task(
|
||||
task_id,
|
||||
user_id,
|
||||
{"selected_source_ids": selected_source_ids, "plan_markdown": plan_markdown},
|
||||
)
|
||||
return _response(row or task)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/plan/ai", response_model=EnterpriseResearchTaskResponse)
|
||||
async def create_ai_plan(
|
||||
request: Request, task_id: str, body: EnterpriseResearchAiPlanCreate
|
||||
) -> EnterpriseResearchTaskResponse:
|
||||
"""Use the configured model to improve a plan, without collecting new evidence."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
task_store = _task_store(request)
|
||||
task = await task_store.get_task(task_id, user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
if not task.get("sources"):
|
||||
raise HTTPException(status_code=409, detail="请先完成证据收集")
|
||||
selected_ids = list(task.get("selected_source_ids") or [])
|
||||
if not selected_ids:
|
||||
selected_ids = [
|
||||
str(item.get("chunk_id") or "")
|
||||
for item in task["sources"][:24]
|
||||
if isinstance(item, dict) and item.get("chunk_id")
|
||||
]
|
||||
selected_ids = _validate_selected_source_ids(task, selected_ids)
|
||||
selected = {
|
||||
str(item.get("chunk_id") or ""): item
|
||||
for item in task.get("sources") or []
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
evidence_titles = "\n".join(
|
||||
f"- {selected[item].get('title') or selected[item].get('filename') or item}"
|
||||
for item in selected_ids
|
||||
if item in selected
|
||||
)
|
||||
current = str(task.get("plan_markdown") or _default_plan(task, selected_ids))
|
||||
backend = DeerFlowCompletionBackend(
|
||||
DeepResearchConfig(mode="basic", language="zh-CN", smart_model=(body.model_name or "").strip() or None).clamp()
|
||||
)
|
||||
try:
|
||||
result = await backend.stream_complete(
|
||||
model_role="smart",
|
||||
operation="enterprise_research_plan",
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是企业研究规划师。仅优化研究目标、核心问题、章节结构和证据使用计划;"
|
||||
"不要撰写报告正文,不要联网,不要虚构资料。仅输出 Markdown 研究计划。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"研究主题:{task['subject']}\n重点范围:{task.get('focus') or '未限定'}\n"
|
||||
f"用户补充要求:{body.instruction or '提高结构完整性和章节关联性'}\n\n"
|
||||
f"当前计划:\n{current}\n\n已选资料标题:\n{evidence_titles}"
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("enterprise research AI plan generation failed task=%s", task_id)
|
||||
raise HTTPException(status_code=502, detail="研究计划生成失败,请检查模型配置后重试") from exc
|
||||
plan = result.text.strip()
|
||||
if plan.startswith("```"):
|
||||
lines = plan.splitlines()[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines.pop()
|
||||
plan = "\n".join(lines).strip()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=502, detail="模型没有返回可用研究计划")
|
||||
row = await task_store.update_task(
|
||||
task_id, user_id, {"plan_markdown": plan[:16_000], "selected_source_ids": selected_ids}
|
||||
)
|
||||
return _response(row or task)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/report-jobs", response_model=EnterpriseResearchReportJobListResponse)
|
||||
async def list_report_jobs(
|
||||
request: Request, task_id: str, limit: int = Query(default=10, ge=1, le=20)
|
||||
) -> EnterpriseResearchReportJobListResponse:
|
||||
user_id, _ = await _actor(request)
|
||||
if await _task_store(request).get_task(task_id, user_id) is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
rows = await _report_job_store(request).list_jobs(task_id, user_id, limit=limit)
|
||||
return EnterpriseResearchReportJobListResponse(jobs=[_report_job_response(row) for row in rows])
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/report-jobs", response_model=EnterpriseResearchReportJobResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_report_job(
|
||||
request: Request, task_id: str, body: EnterpriseResearchReportJobCreate
|
||||
) -> EnterpriseResearchReportJobResponse:
|
||||
"""Freeze the approved plan/evidence, then launch an independent report job."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
task_store = _task_store(request)
|
||||
job_store = _report_job_store(request)
|
||||
executor = _report_executor(request)
|
||||
async with _report_start_lock(request):
|
||||
task = await task_store.get_task(task_id, user_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
active = await job_store.get_active_for_task(task_id, user_id)
|
||||
if active is not None:
|
||||
await executor.ensure_started(active["id"], user_id)
|
||||
return _report_job_response(active)
|
||||
plan = str(task.get("plan_markdown") or "").strip()
|
||||
selected_ids = _validate_selected_source_ids(task, list(task.get("selected_source_ids") or []))
|
||||
if not plan:
|
||||
raise HTTPException(status_code=409, detail="请先生成并保存研究计划")
|
||||
if not selected_ids:
|
||||
raise HTTPException(status_code=409, detail="请至少选择一条资料后再撰写报告")
|
||||
sources_by_id = {
|
||||
str(item.get("chunk_id") or ""): item
|
||||
for item in task.get("sources") or []
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
sources = [sources_by_id[source_id] for source_id in selected_ids if source_id in sources_by_id]
|
||||
if not sources:
|
||||
raise HTTPException(status_code=409, detail="已选资料不可用,请重新保存研究计划")
|
||||
if body.action in {"rewrite", "followup"} and not task.get("report_markdown"):
|
||||
raise HTTPException(status_code=409, detail="当前任务还没有可供改写或追问的报告")
|
||||
if body.action == "followup" and not body.instruction.strip():
|
||||
raise HTTPException(status_code=422, detail="请填写报告追问")
|
||||
row = await job_store.create_job(
|
||||
{
|
||||
"id": f"erj_{uuid4().hex}", "task_id": task_id, "user_id": user_id,
|
||||
"model_name": (body.model_name or "").strip() or None,
|
||||
"action": body.action, "instruction": body.instruction.strip(), "style": body.style,
|
||||
"target_length": body.target_length, "plan_snapshot": plan, "sources": sources,
|
||||
"original_report": task.get("report_markdown") if body.action in {"rewrite", "followup"} else None,
|
||||
}
|
||||
)
|
||||
await task_store.update_task(task_id, user_id, {"active_report_job_id": row["id"]})
|
||||
await executor.ensure_started(row["id"], user_id)
|
||||
return _report_job_response(row)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/report-jobs/{job_id}/stream")
|
||||
async def stream_report_job(request: Request, task_id: str, job_id: str) -> StreamingResponse:
|
||||
"""SSE stream: durable draft snapshot first, then live Markdown/reasoning deltas."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
job = await _report_job_store(request).get_job(job_id, user_id)
|
||||
if job is None or job.get("task_id") != task_id:
|
||||
raise HTTPException(status_code=404, detail="报告任务不存在")
|
||||
if job["status"] == "queued":
|
||||
await _report_executor(request).ensure_started(job_id, user_id)
|
||||
|
||||
async def body():
|
||||
async for event in _report_executor(request).stream_events(job_id, user_id):
|
||||
yield "event: enterprise_research\n"
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
body(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/report-jobs/{job_id}/cancel",
|
||||
response_model=EnterpriseResearchReportJobResponse,
|
||||
)
|
||||
async def cancel_report_job(request: Request, task_id: str, job_id: str) -> EnterpriseResearchReportJobResponse:
|
||||
user_id, _ = await _actor(request)
|
||||
job = await _report_job_store(request).get_job(job_id, user_id)
|
||||
if job is None or job.get("task_id") != task_id:
|
||||
raise HTTPException(status_code=404, detail="报告任务不存在")
|
||||
cancelled = await _report_executor(request).cancel(job_id, user_id)
|
||||
return _report_job_response(cancelled or job)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/report-jobs/{job_id}/restore",
|
||||
response_model=EnterpriseResearchTaskResponse,
|
||||
)
|
||||
async def restore_report_version(request: Request, task_id: str, job_id: str) -> EnterpriseResearchTaskResponse:
|
||||
"""Restore the before-image frozen by a completed full-report rewrite."""
|
||||
|
||||
user_id, _ = await _actor(request)
|
||||
job = await _report_job_store(request).get_job(job_id, user_id)
|
||||
if job is None or job.get("task_id") != task_id:
|
||||
raise HTTPException(status_code=404, detail="报告任务不存在")
|
||||
original = str(job.get("original_report") or "")
|
||||
if job.get("status") != "completed" or job.get("action") != "rewrite" or not original.strip():
|
||||
raise HTTPException(status_code=409, detail="该任务没有可恢复的改写前版本")
|
||||
if await _report_job_store(request).get_active_for_task(task_id, user_id) is not None:
|
||||
raise HTTPException(status_code=409, detail="报告任务正在执行,请完成或停止后再恢复版本")
|
||||
task_store = _task_store(request)
|
||||
current = await task_store.get_task(task_id, user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
current_report = str(current.get("report_markdown") or "")
|
||||
if not current_report.strip():
|
||||
raise HTTPException(status_code=409, detail="当前没有可替换的报告")
|
||||
# The restore itself is a versioned operation: its before-image allows the
|
||||
# user to reverse a mistaken restore through the same version UI.
|
||||
await _report_job_store(request).create_job(
|
||||
{
|
||||
"id": f"erj_{uuid4().hex}", "task_id": task_id, "user_id": user_id,
|
||||
"status": "completed", "phase": "completed", "action": "rewrite",
|
||||
"instruction": f"恢复报告任务 {job_id} 的改写前版本", "style": "formal",
|
||||
"model_name": current.get("report_model_name"),
|
||||
"plan_snapshot": str(current.get("plan_markdown") or ""),
|
||||
"sources": [
|
||||
item for item in current.get("sources") or []
|
||||
if isinstance(item, dict) and item.get("chunk_id") in set(current.get("selected_source_ids") or [])
|
||||
],
|
||||
"original_report": current_report, "report_markdown": original,
|
||||
"report_summary": f"已恢复到报告任务 {job_id} 的改写前版本。",
|
||||
}
|
||||
)
|
||||
task = await task_store.update_task(
|
||||
task_id,
|
||||
user_id,
|
||||
{
|
||||
"report_markdown": original,
|
||||
"report_summary": f"已恢复到报告任务 {job_id} 的改写前版本。",
|
||||
"active_report_job_id": None,
|
||||
},
|
||||
)
|
||||
assert task is not None
|
||||
return _response(task)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_task(request: Request, task_id: str) -> None:
|
||||
user_id, _ = await _actor(request)
|
||||
if not await _task_store(request).delete_task(task_id, user_id):
|
||||
raise HTTPException(status_code=404, detail="研究任务不存在")
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
from urllib.parse import quote, unquote, urlencode, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -27,11 +27,21 @@ from app.gateway.llmwiki_deposit import (
|
||||
ensure_conversation_deposit_mapping,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
||||
WEKNORA_EMBED_API_PREFIX as _WEKNORA_EMBED_API_PREFIX,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE as _WEKNORA_EMBED_COOKIE,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_TTL_SECONDS as _WEKNORA_EMBED_TTL_SECONDS,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
WeKnoraEmbedContext,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
sign_weknora_embed_payload as _sign_embed_payload,
|
||||
)
|
||||
from app.gateway.weknora_embed import (
|
||||
verify_weknora_embed_cookie as _verify_embed_cookie,
|
||||
)
|
||||
from deerflow.config.agents_config import load_agent_config
|
||||
@@ -292,21 +302,9 @@ def _admin_session_cache_key(runtime: Any) -> str:
|
||||
def _extract_weknora_token(payload: dict[str, Any]) -> tuple[str, str, dict[str, Any] | None, dict[str, Any] | None]:
|
||||
source = payload.get("data") if isinstance(payload.get("data"), dict) else payload
|
||||
token = str(source.get("token") or source.get("access_token") or payload.get("token") or payload.get("access_token") or "")
|
||||
refresh_token = str(
|
||||
source.get("refresh_token")
|
||||
or source.get("refreshToken")
|
||||
or payload.get("refresh_token")
|
||||
or payload.get("refreshToken")
|
||||
or ""
|
||||
)
|
||||
refresh_token = str(source.get("refresh_token") or source.get("refreshToken") or payload.get("refresh_token") or payload.get("refreshToken") or "")
|
||||
user = source.get("user") if isinstance(source.get("user"), dict) else payload.get("user")
|
||||
tenant = (
|
||||
source.get("tenant")
|
||||
if isinstance(source.get("tenant"), dict)
|
||||
else source.get("active_tenant")
|
||||
if isinstance(source.get("active_tenant"), dict)
|
||||
else payload.get("tenant")
|
||||
)
|
||||
tenant = source.get("tenant") if isinstance(source.get("tenant"), dict) else source.get("active_tenant") if isinstance(source.get("active_tenant"), dict) else payload.get("tenant")
|
||||
return token, refresh_token, user if isinstance(user, dict) else None, tenant if isinstance(tenant, dict) else None
|
||||
|
||||
|
||||
@@ -377,6 +375,79 @@ async def _read_embed_context(request: Request) -> WeKnoraEmbedContext:
|
||||
ctx,
|
||||
can_write=writable is not None,
|
||||
is_conversation_deposit=_is_conversation_deposit_mapping(readable),
|
||||
allowed_weknora_ids=_embed_allowed_weknora_ids(ctx),
|
||||
)
|
||||
|
||||
|
||||
def _embed_allowed_weknora_ids(ctx: WeKnoraEmbedContext) -> tuple[str, ...]:
|
||||
"""Return the signed knowledge-base allowlist, always retaining the active base."""
|
||||
|
||||
return tuple(dict.fromkeys(value for value in (ctx.weknora_id, *ctx.allowed_weknora_ids) if value))
|
||||
|
||||
|
||||
def _embed_context_payload(ctx: WeKnoraEmbedContext) -> dict[str, Any]:
|
||||
return {
|
||||
"mapping_id": ctx.mapping_id,
|
||||
"weknora_id": ctx.weknora_id,
|
||||
"user_id": ctx.user_id,
|
||||
"is_admin": ctx.is_admin,
|
||||
"can_write": ctx.can_write,
|
||||
"is_conversation_deposit": ctx.is_conversation_deposit,
|
||||
"allowed_weknora_ids": list(_embed_allowed_weknora_ids(ctx)),
|
||||
"exp": ctx.exp,
|
||||
}
|
||||
|
||||
|
||||
def _weknora_id_from_platform_path(path: str) -> str | None:
|
||||
prefix = "/platform/knowledge-bases/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
raw_id = path.removeprefix(prefix)
|
||||
if not raw_id or "/" in raw_id:
|
||||
return None
|
||||
return unquote(raw_id)
|
||||
|
||||
|
||||
async def _switch_embed_context(
|
||||
request: Request,
|
||||
ctx: WeKnoraEmbedContext,
|
||||
weknora_id: str,
|
||||
) -> WeKnoraEmbedContext:
|
||||
"""Resolve a permitted native-UI switch to the exact cmzs mapping.
|
||||
|
||||
The embedded WeKnora UI uses a service-admin token, so every target remains
|
||||
constrained to an ID signed into this short-lived session and is checked
|
||||
again against the cmzs visibility rules before its page can be proxied.
|
||||
"""
|
||||
|
||||
if weknora_id not in _embed_allowed_weknora_ids(ctx):
|
||||
raise HTTPException(status_code=403, detail="Knowledge base is outside the embedded session scope")
|
||||
store = _store(request)
|
||||
mapped = await store.get_by_weknora_id(weknora_id)
|
||||
if mapped is None:
|
||||
raise HTTPException(status_code=403, detail="Knowledge base is not managed by cmzs")
|
||||
readable = await store.get_authorized(
|
||||
str(mapped["id"]),
|
||||
ctx.user_id,
|
||||
write=False,
|
||||
is_admin=ctx.is_admin,
|
||||
)
|
||||
if readable is None or str(readable.get("weknora_id") or "") != weknora_id:
|
||||
raise HTTPException(status_code=403, detail="Knowledge base is no longer authorized")
|
||||
writable = None
|
||||
if not _is_conversation_deposit_mapping(readable):
|
||||
writable = await store.get_authorized(
|
||||
str(readable["id"]),
|
||||
ctx.user_id,
|
||||
write=True,
|
||||
is_admin=ctx.is_admin,
|
||||
)
|
||||
return replace(
|
||||
ctx,
|
||||
mapping_id=str(readable["id"]),
|
||||
weknora_id=weknora_id,
|
||||
can_write=writable is not None,
|
||||
is_conversation_deposit=_is_conversation_deposit_mapping(readable),
|
||||
)
|
||||
|
||||
|
||||
@@ -431,12 +502,7 @@ def _body_kb_id(body: Any) -> str:
|
||||
|
||||
|
||||
def _request_kb_id(request: Request, body: Any) -> str:
|
||||
return _body_kb_id(body) or str(
|
||||
request.query_params.get("kb_id")
|
||||
or request.query_params.get("knowledge_base_id")
|
||||
or request.query_params.get("source_kb_id")
|
||||
or ""
|
||||
)
|
||||
return _body_kb_id(body) or str(request.query_params.get("kb_id") or request.query_params.get("knowledge_base_id") or request.query_params.get("source_kb_id") or "")
|
||||
|
||||
|
||||
async def _admin_json(runtime: Any, path: str, *, token: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -533,12 +599,7 @@ async def _authorize_weknora_api_proxy(
|
||||
raise HTTPException(status_code=403, detail="Global WeKnora knowledge collection is not allowed")
|
||||
action = tail[1]
|
||||
if read_method and action == "batch":
|
||||
ids = [
|
||||
item.strip()
|
||||
for raw in request.query_params.getlist("ids")
|
||||
for item in raw.split(",")
|
||||
if item.strip()
|
||||
]
|
||||
ids = [item.strip() for raw in request.query_params.getlist("ids") for item in raw.split(",") if item.strip()]
|
||||
if not ids:
|
||||
raise HTTPException(status_code=403, detail="Batch operation must include document ids")
|
||||
for document_id in ids:
|
||||
@@ -574,14 +635,19 @@ async def _authorize_weknora_api_proxy(
|
||||
# A few read-only metadata endpoints are required by the original detail
|
||||
# page to render upload controls and current-tenant state. They do not
|
||||
# expose other knowledge-base contents.
|
||||
if read_method and tail and tail[0] in {
|
||||
"models",
|
||||
"system",
|
||||
"storage-backends",
|
||||
"vector-stores",
|
||||
"web-search-providers",
|
||||
"mcp-services",
|
||||
}:
|
||||
if (
|
||||
read_method
|
||||
and tail
|
||||
and tail[0]
|
||||
in {
|
||||
"models",
|
||||
"system",
|
||||
"storage-backends",
|
||||
"vector-stores",
|
||||
"web-search-providers",
|
||||
"mcp-services",
|
||||
}
|
||||
):
|
||||
return
|
||||
|
||||
raise HTTPException(status_code=403, detail="This WeKnora endpoint is blocked in embedded detail mode")
|
||||
@@ -598,27 +664,29 @@ def _filter_knowledge_base_list_response(response: httpx.Response, ctx: WeKnoraE
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
def keep_current(value: Any) -> Any:
|
||||
allowed_weknora_ids = set(_embed_allowed_weknora_ids(ctx))
|
||||
|
||||
def keep_allowed(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if isinstance(item, dict) and str(item.get("id") or "") == ctx.weknora_id]
|
||||
return [item for item in value if isinstance(item, dict) and str(item.get("id") or "") in allowed_weknora_ids]
|
||||
if isinstance(value, dict):
|
||||
result = dict(value)
|
||||
for key in ("items", "list", "knowledge_bases", "knowledge", "records", "data"):
|
||||
if isinstance(result.get(key), list):
|
||||
result[key] = keep_current(result[key])
|
||||
result[key] = keep_allowed(result[key])
|
||||
result["total"] = len(result[key])
|
||||
return result
|
||||
return value
|
||||
|
||||
if isinstance(payload.get("data"), list):
|
||||
payload["data"] = keep_current(payload["data"])
|
||||
payload["data"] = keep_allowed(payload["data"])
|
||||
payload["total"] = len(payload["data"])
|
||||
elif isinstance(payload.get("data"), dict):
|
||||
payload["data"] = keep_current(payload["data"])
|
||||
payload["data"] = keep_allowed(payload["data"])
|
||||
else:
|
||||
for key in ("items", "list", "knowledge_bases", "records"):
|
||||
if isinstance(payload.get(key), list):
|
||||
payload[key] = keep_current(payload[key])
|
||||
payload[key] = keep_allowed(payload[key])
|
||||
payload["total"] = len(payload[key])
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
@@ -654,7 +722,7 @@ def _with_public_prefix(prefix: str, path: str) -> str:
|
||||
|
||||
def _without_public_prefix(prefix: str, path: str) -> str:
|
||||
if prefix and path.startswith(f"{prefix}/"):
|
||||
return path[len(prefix):]
|
||||
return path[len(prefix) :]
|
||||
if prefix and path == prefix:
|
||||
return "/"
|
||||
return path
|
||||
@@ -826,6 +894,7 @@ def _weknora_embed_injection(
|
||||
"user": user,
|
||||
"tenant": tenant,
|
||||
"weknoraId": ctx.weknora_id,
|
||||
"allowedWeKnoraIds": list(_embed_allowed_weknora_ids(ctx)),
|
||||
"publicPrefix": public_prefix,
|
||||
"apiPrefix": _with_public_prefix(public_prefix, _WEKNORA_EMBED_API_PREFIX),
|
||||
}
|
||||
@@ -896,8 +965,7 @@ html.deerflow-weknora-embed .document-header {{
|
||||
padding-right: 32px !important;
|
||||
background: var(--td-bg-color-page) !important;
|
||||
}}
|
||||
html.deerflow-weknora-embed .breadcrumb-link:first-child,
|
||||
html.deerflow-weknora-embed .breadcrumb-link.dropdown {{
|
||||
html.deerflow-weknora-embed .breadcrumb-link:first-child {{
|
||||
pointer-events: none !important;
|
||||
cursor: default !important;
|
||||
}}
|
||||
@@ -1223,11 +1291,24 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
}}
|
||||
|
||||
const allowedPath = cfg.allowedPath;
|
||||
const allowedWeKnoraIds = new Set(
|
||||
[cfg.weknoraId, ...(Array.isArray(cfg.allowedWeKnoraIds) ? cfg.allowedWeKnoraIds : [])]
|
||||
.filter(Boolean)
|
||||
);
|
||||
const knowledgeBasePathPrefix = (cfg.publicPrefix || "") + "/platform/knowledge-bases/";
|
||||
function isAllowedKnowledgeBasePath(pathname) {{
|
||||
if (!pathname.startsWith(knowledgeBasePathPrefix)) return false;
|
||||
const encodedId = pathname.slice(knowledgeBasePathPrefix.length);
|
||||
if (!encodedId || encodedId.includes("/")) return false;
|
||||
try {{ return allowedWeKnoraIds.has(decodeURIComponent(encodedId)); }} catch (_) {{ return false; }}
|
||||
}}
|
||||
function allowedUrl(url) {{
|
||||
try {{
|
||||
const next = new URL(url, location.origin);
|
||||
if (next.origin !== location.origin) return null;
|
||||
if (next.pathname === allowedPath) return next.pathname + next.search + next.hash;
|
||||
if (next.pathname === allowedPath || isAllowedKnowledgeBasePath(next.pathname)) {{
|
||||
return next.pathname + next.search + next.hash;
|
||||
}}
|
||||
const query = new URLSearchParams(location.search);
|
||||
if (cfg.tab && !query.has("tab")) query.set("tab", cfg.tab);
|
||||
return allowedPath + (query.toString() ? "?" + query.toString() : "") + location.hash;
|
||||
@@ -1236,10 +1317,21 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
}}
|
||||
}}
|
||||
function enforceLocation() {{
|
||||
if (location.pathname !== allowedPath) {{
|
||||
if (location.pathname !== allowedPath && !isAllowedKnowledgeBasePath(location.pathname)) {{
|
||||
history.replaceState(history.state, "", allowedPath + location.search + location.hash);
|
||||
}}
|
||||
}}
|
||||
function reloadForKnowledgeBaseSwitch(target, replace) {{
|
||||
try {{
|
||||
const next = new URL(target, location.origin);
|
||||
if (next.pathname === location.pathname || !isAllowedKnowledgeBasePath(next.pathname)) return false;
|
||||
if (replace) location.replace(next.href);
|
||||
else location.assign(next.href);
|
||||
return true;
|
||||
}} catch (_) {{
|
||||
return false;
|
||||
}}
|
||||
}}
|
||||
function purgeGuides() {{
|
||||
document
|
||||
.querySelectorAll(
|
||||
@@ -1339,8 +1431,16 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
}}
|
||||
const rawPushState = history.pushState.bind(history);
|
||||
const rawReplaceState = history.replaceState.bind(history);
|
||||
history.pushState = (state, title, url) => rawPushState(state, title, url ? allowedUrl(url) : location.href);
|
||||
history.replaceState = (state, title, url) => rawReplaceState(state, title, url ? allowedUrl(url) : location.href);
|
||||
history.pushState = (state, title, url) => {{
|
||||
const target = url ? allowedUrl(url) : location.href;
|
||||
if (url && target && reloadForKnowledgeBaseSwitch(target, false)) return;
|
||||
return rawPushState(state, title, target);
|
||||
}};
|
||||
history.replaceState = (state, title, url) => {{
|
||||
const target = url ? allowedUrl(url) : location.href;
|
||||
if (url && target && reloadForKnowledgeBaseSwitch(target, true)) return;
|
||||
return rawReplaceState(state, title, target);
|
||||
}};
|
||||
window.addEventListener("popstate", enforceLocation);
|
||||
document.addEventListener("click", (event) => {{
|
||||
const anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
|
||||
@@ -1349,6 +1449,7 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
if (target && new URL(anchor.href, location.origin).pathname !== allowedPath) {{
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (reloadForKnowledgeBaseSwitch(target, false)) return;
|
||||
history.replaceState(history.state, "", target);
|
||||
}}
|
||||
}}, true);
|
||||
@@ -1365,7 +1466,7 @@ html.deerflow-source-detail-open [data-deerflow-source-detail-panel="true"] [cla
|
||||
hideWeKnoraShell();
|
||||
tickSourceDetail();
|
||||
if (ready) return;
|
||||
if (!force && (location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||
if (!force && (!isAllowedKnowledgeBasePath(location.pathname) && location.pathname !== allowedPath || !hasDetailShell())) return;
|
||||
ready = true;
|
||||
root.classList.add("deerflow-weknora-ready");
|
||||
}}
|
||||
@@ -1553,10 +1654,7 @@ async def _proxy_weknora_request(
|
||||
|
||||
|
||||
def _is_conversation_deposit_mapping(row: dict[str, Any]) -> bool:
|
||||
return (
|
||||
str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID
|
||||
and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||||
)
|
||||
return str(row.get("owner_user_id") or "") == CONVERSATION_DEPOSIT_OWNER_USER_ID and str(row.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||||
|
||||
|
||||
_LEGACY_BRAND_PATTERN = re.compile(r"deer[\s_-]*flow", re.IGNORECASE)
|
||||
@@ -1612,13 +1710,7 @@ def _mapping_view(row: dict[str, Any], remote: dict[str, Any] | None, *, actor_u
|
||||
source = remote or {}
|
||||
is_conversation_deposit = _is_conversation_deposit_mapping(row)
|
||||
is_owner = row.get("owner_user_id") == actor_user_id
|
||||
description = (
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
if is_conversation_deposit
|
||||
else source.get("description")
|
||||
if source.get("description") is not None
|
||||
else row.get("description") or ""
|
||||
)
|
||||
description = CONVERSATION_DEPOSIT_KB_DESCRIPTION if is_conversation_deposit else source.get("description") if source.get("description") is not None else row.get("description") or ""
|
||||
return {
|
||||
"id": row["id"],
|
||||
"remote_status": "available" if remote is not None else "missing",
|
||||
@@ -1871,19 +1963,28 @@ async def _prepare_weknora_embed_session(
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
_runtime_for_iframe_proxy(request)
|
||||
row, user_id, is_admin = await _authorized_mapping(request, mapping_id, write=False)
|
||||
visible_rows = await _store(request).list_visible(
|
||||
user_id,
|
||||
scope="all",
|
||||
# The iframe must mirror the knowledge-base picker, not grant an
|
||||
# administrator an unlisted user's private knowledge bases.
|
||||
is_admin=False,
|
||||
)
|
||||
writable = None
|
||||
if not _is_conversation_deposit_mapping(row):
|
||||
writable = await _store(request).get_authorized(mapping_id, user_id, write=True, is_admin=is_admin)
|
||||
weknora_id = str(row["weknora_id"])
|
||||
return weknora_id, {
|
||||
"mapping_id": mapping_id,
|
||||
"weknora_id": weknora_id,
|
||||
"user_id": user_id,
|
||||
"is_admin": is_admin,
|
||||
"can_write": writable is not None,
|
||||
"is_conversation_deposit": _is_conversation_deposit_mapping(row),
|
||||
"exp": int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||||
}
|
||||
ctx = WeKnoraEmbedContext(
|
||||
mapping_id=mapping_id,
|
||||
weknora_id=weknora_id,
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
can_write=writable is not None,
|
||||
is_conversation_deposit=_is_conversation_deposit_mapping(row),
|
||||
allowed_weknora_ids=tuple(dict.fromkeys(value for value in (weknora_id, *(str(item.get("weknora_id") or "") for item in visible_rows)) if value)),
|
||||
exp=int(time.time()) + _WEKNORA_EMBED_TTL_SECONDS,
|
||||
)
|
||||
return weknora_id, _embed_context_payload(ctx)
|
||||
|
||||
|
||||
@router.post("/api/llmwiki/knowledge-bases/{mapping_id}/weknora/session")
|
||||
@@ -1932,8 +2033,21 @@ async def open_weknora_detail_frame(
|
||||
async def proxy_weknora_platform_page(request: Request, proxied_path: str = "") -> Response:
|
||||
ctx = await _read_embed_context(request)
|
||||
requested = f"/platform/{proxied_path}".rstrip("/")
|
||||
allowed_path = f"/platform/knowledge-bases/{ctx.weknora_id}"
|
||||
allowed_path = f"/platform/knowledge-bases/{quote(ctx.weknora_id, safe='')}"
|
||||
if requested != allowed_path:
|
||||
requested_weknora_id = _weknora_id_from_platform_path(requested)
|
||||
if requested_weknora_id is not None:
|
||||
selected_ctx = await _switch_embed_context(request, ctx, requested_weknora_id)
|
||||
response = await _proxy_weknora_request(
|
||||
request,
|
||||
target_base_url=_runtime_for_iframe_proxy(request).web_base_url,
|
||||
upstream_path=f"/platform/knowledge-bases/{quote(requested_weknora_id, safe='')}",
|
||||
ctx=selected_ctx,
|
||||
is_api=False,
|
||||
tab=request.query_params.get("tab") or "wiki",
|
||||
)
|
||||
_set_weknora_embed_cookie(request, response, _embed_context_payload(selected_ctx))
|
||||
return response
|
||||
static_upstream_path = _weknora_static_fallback_upstream_path(request)
|
||||
if static_upstream_path is not None:
|
||||
runtime = _runtime_for_iframe_proxy(request)
|
||||
@@ -2151,11 +2265,7 @@ async def list_knowledge_bases(
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
deposit_remote = next(
|
||||
(
|
||||
item
|
||||
for item in remote_items
|
||||
if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME
|
||||
),
|
||||
(item for item in remote_items if str(item.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME),
|
||||
None,
|
||||
)
|
||||
if deposit_remote is not None:
|
||||
@@ -2165,11 +2275,7 @@ async def list_knowledge_bases(
|
||||
client,
|
||||
remote_knowledge_bases=remote_items,
|
||||
)
|
||||
description_was_synchronized = (
|
||||
previous_mapping is None
|
||||
or not _is_conversation_deposit_mapping(previous_mapping)
|
||||
or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
)
|
||||
description_was_synchronized = previous_mapping is None or not _is_conversation_deposit_mapping(previous_mapping) or str(previous_mapping.get("description") or "") != CONVERSATION_DEPOSIT_KB_DESCRIPTION
|
||||
if deposit_mapping is not None and description_was_synchronized:
|
||||
remote_id = str(deposit_mapping["weknora_id"])
|
||||
remote[remote_id] = {
|
||||
@@ -2409,13 +2515,7 @@ async def list_wiki_pages(
|
||||
query=query.strip(),
|
||||
)
|
||||
raw_pages = result.get("pages") if isinstance(result, dict) else []
|
||||
pages = [
|
||||
view
|
||||
for page_item in (raw_pages if isinstance(raw_pages, list) else [])
|
||||
if isinstance(page_item, dict)
|
||||
for view in [_wiki_page_view(page_item)]
|
||||
if view is not None
|
||||
]
|
||||
pages = [view for page_item in (raw_pages if isinstance(raw_pages, list) else []) if isinstance(page_item, dict) for view in [_wiki_page_view(page_item)] if view is not None]
|
||||
return {**result, "pages": pages}
|
||||
except WeKnoraError as exc:
|
||||
_raise_upstream(exc)
|
||||
@@ -2557,11 +2657,7 @@ async def search_llmwiki(request: Request, body: SearchRequest) -> dict[str, Any
|
||||
mapping = local_by_remote.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None:
|
||||
continue
|
||||
visible_result = (
|
||||
_sanitize_conversation_deposit_value(result)
|
||||
if _is_conversation_deposit_mapping(mapping)
|
||||
else result
|
||||
)
|
||||
visible_result = _sanitize_conversation_deposit_value(result) if _is_conversation_deposit_mapping(mapping) else result
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": visible_result.get("chunk_id") or ""})
|
||||
visible.append(
|
||||
{
|
||||
@@ -2665,28 +2761,13 @@ async def get_source_context(
|
||||
if sanitize_deposit and wiki_page is not None:
|
||||
wiki_page = _sanitize_conversation_deposit_value(wiki_page)
|
||||
focus_chunk = next((chunk for chunk in chunks if str(chunk.get("id") or "") == chunk_id), {})
|
||||
document_id = str(
|
||||
focus_chunk.get("knowledge_id")
|
||||
or focus_chunk.get("document_id")
|
||||
or focus_chunk.get("knowledgeId")
|
||||
or ""
|
||||
)
|
||||
document_title = str(
|
||||
focus_chunk.get("knowledge_title")
|
||||
or focus_chunk.get("title")
|
||||
or focus_chunk.get("file_name")
|
||||
or focus_chunk.get("filename")
|
||||
or ""
|
||||
)
|
||||
document_id = str(focus_chunk.get("knowledge_id") or focus_chunk.get("document_id") or focus_chunk.get("knowledgeId") or "")
|
||||
document_title = str(focus_chunk.get("knowledge_title") or focus_chunk.get("title") or focus_chunk.get("file_name") or focus_chunk.get("filename") or "")
|
||||
normalized_chunks = [
|
||||
{
|
||||
"id": str(chunk.get("id") or ""),
|
||||
"chunk_index": int(chunk.get("chunk_index") or 0),
|
||||
"content": str(
|
||||
_sanitize_conversation_deposit_value(chunk.get("content") or "")
|
||||
if sanitize_deposit
|
||||
else chunk.get("content") or ""
|
||||
),
|
||||
"content": str(_sanitize_conversation_deposit_value(chunk.get("content") or "") if sanitize_deposit else chunk.get("content") or ""),
|
||||
"is_focus": str(chunk.get("id") or "") == chunk_id,
|
||||
}
|
||||
for chunk in chunks
|
||||
|
||||
@@ -71,6 +71,9 @@ class ChainSeatSchema(BaseModel):
|
||||
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
|
||||
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
|
||||
position_id: str | None = Field(default=None, max_length=64)
|
||||
# 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段;
|
||||
# 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。
|
||||
job_responsibilities: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class ChainResponse(BaseModel):
|
||||
|
||||
@@ -76,6 +76,7 @@ class WeKnoraEmbedContext:
|
||||
can_write: bool
|
||||
exp: int
|
||||
is_conversation_deposit: bool = False
|
||||
allowed_weknora_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_path(path: str) -> bool:
|
||||
@@ -109,9 +110,7 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
if not raw or "." not in raw:
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is missing")
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
expected = _b64url_encode(
|
||||
hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest()
|
||||
)
|
||||
expected = _b64url_encode(hmac.new(WEKNORA_PROXY_SECRET.encode("utf-8"), body.encode("ascii"), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid")
|
||||
try:
|
||||
@@ -120,6 +119,8 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session is invalid") from None
|
||||
if int(payload.get("exp") or 0) < int(time.time()):
|
||||
raise HTTPException(status_code=401, detail="WeKnora iframe session has expired")
|
||||
raw_allowed_ids = payload.get("allowed_weknora_ids")
|
||||
allowed_weknora_ids = tuple(dict.fromkeys(str(item).strip() for item in raw_allowed_ids if isinstance(item, (str, int)) and str(item).strip())) if isinstance(raw_allowed_ids, list) else ()
|
||||
return WeKnoraEmbedContext(
|
||||
mapping_id=str(payload.get("mapping_id") or ""),
|
||||
weknora_id=str(payload.get("weknora_id") or ""),
|
||||
@@ -128,6 +129,7 @@ def verify_weknora_embed_cookie(raw: str | None) -> WeKnoraEmbedContext:
|
||||
can_write=bool(payload.get("can_write")),
|
||||
exp=int(payload.get("exp") or 0),
|
||||
is_conversation_deposit=bool(payload.get("is_conversation_deposit")),
|
||||
allowed_weknora_ids=allowed_weknora_ids,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -109,9 +109,6 @@ class AgentConfig(BaseModel):
|
||||
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
|
||||
# ``app/gateway/roundtable_seat_skills.py``。
|
||||
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
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
"""Enterprise-research task persistence."""
|
||||
|
||||
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
|
||||
from deerflow.persistence.enterprise_research.memory import MemoryEnterpriseResearchReportJobStore, MemoryEnterpriseResearchTaskStore
|
||||
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
|
||||
from deerflow.persistence.enterprise_research.sql import EnterpriseResearchReportJobRepository, EnterpriseResearchTaskRepository
|
||||
|
||||
__all__ = [
|
||||
"EnterpriseResearchTaskRow",
|
||||
"EnterpriseResearchReportJobRow",
|
||||
"EnterpriseResearchTaskRepository",
|
||||
"EnterpriseResearchReportJobRepository",
|
||||
"EnterpriseResearchTaskStore",
|
||||
"EnterpriseResearchReportJobStore",
|
||||
"MemoryEnterpriseResearchTaskStore",
|
||||
"MemoryEnterpriseResearchReportJobStore",
|
||||
"make_enterprise_research_task_store",
|
||||
"make_enterprise_research_report_job_store",
|
||||
]
|
||||
|
||||
|
||||
def make_enterprise_research_task_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryEnterpriseResearchTaskStore()
|
||||
return EnterpriseResearchTaskRepository(session_factory)
|
||||
|
||||
|
||||
def make_enterprise_research_report_job_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryEnterpriseResearchReportJobStore()
|
||||
return EnterpriseResearchReportJobRepository(session_factory)
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Persistence contract for the independent enterprise-research workbench."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class EnterpriseResearchTaskStore(ABC):
|
||||
"""Owner-scoped task storage; evidence snapshots are DeerFlow-owned."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobStore(ABC):
|
||||
"""Owner-scoped, frozen-input jobs for enterprise report writing."""
|
||||
|
||||
@abstractmethod
|
||||
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def claim_job(self, job_id: str, user_id: str) -> bool:
|
||||
"""Atomically move one queued job to running."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
|
||||
"""Atomically requeue the exact running snapshot seen during recovery."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def transition_job(
|
||||
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Apply a terminal/state transition only from one of the expected states."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""In-memory fallback for enterprise-research tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class MemoryEnterpriseResearchTaskStore(EnterpriseResearchTaskStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
rows = [dict(row) for row in self._rows.values() if row["user_id"] == user_id]
|
||||
return sorted(rows, key=lambda row: row["updated_at"], reverse=True)[:limit]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(task_id)
|
||||
return dict(row) if row is not None and row["user_id"] == user_id else None
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = _now()
|
||||
row = {
|
||||
"id": str(data["id"]),
|
||||
"user_id": str(data["user_id"]),
|
||||
"subject": str(data["subject"]),
|
||||
"focus": str(data.get("focus") or ""),
|
||||
"template_id": str(data["template_id"]),
|
||||
"knowledge_base_ids": list(data.get("knowledge_base_ids") or []),
|
||||
"queries": list(data.get("queries") or []),
|
||||
"selected_source_ids": list(data.get("selected_source_ids") or []),
|
||||
"sources": list(data.get("sources") or []),
|
||||
"plan_markdown": data.get("plan_markdown"),
|
||||
"report_markdown": data.get("report_markdown"),
|
||||
"report_summary": data.get("report_summary"),
|
||||
"report_model_name": data.get("report_model_name"),
|
||||
"active_report_job_id": data.get("active_report_job_id"),
|
||||
"status": str(data.get("status") or "draft"),
|
||||
"warning": data.get("warning"),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._rows.get(task_id)
|
||||
if row is None or row["user_id"] != user_id:
|
||||
return None
|
||||
for key in (
|
||||
"status", "warning", "sources", "selected_source_ids", "plan_markdown",
|
||||
"report_markdown", "report_summary", "report_model_name", "active_report_job_id",
|
||||
):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
row = self._rows.get(task_id)
|
||||
if row is None or row["user_id"] != user_id:
|
||||
return False
|
||||
del self._rows[task_id]
|
||||
return True
|
||||
|
||||
|
||||
class MemoryEnterpriseResearchReportJobStore(EnterpriseResearchReportJobStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = _now()
|
||||
status = str(data.get("status") or "queued")
|
||||
active_dedupe_key = (
|
||||
f"{data['user_id']}:{data['task_id']}" if status in {"queued", "running"} else None
|
||||
)
|
||||
if active_dedupe_key:
|
||||
existing = next(
|
||||
(row for row in self._rows.values() if row.get("active_dedupe_key") == active_dedupe_key),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
return dict(existing)
|
||||
row = {
|
||||
"id": str(data["id"]), "task_id": str(data["task_id"]), "user_id": str(data["user_id"]),
|
||||
"status": status, "phase": str(data.get("phase") or "queued"),
|
||||
"model_name": data.get("model_name"), "action": str(data.get("action") or "initial"),
|
||||
"active_dedupe_key": active_dedupe_key,
|
||||
"instruction": data.get("instruction"), "style": data.get("style"),
|
||||
"target_length": data.get("target_length"), "plan_snapshot": str(data["plan_snapshot"]),
|
||||
"sources": list(data.get("sources") or []), "report_markdown": str(data.get("report_markdown") or ""),
|
||||
"original_report": data.get("original_report"),
|
||||
"report_summary": data.get("report_summary"), "error_message": data.get("error_message"),
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return dict(row)
|
||||
|
||||
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(job_id)
|
||||
return dict(row) if row is not None and row["user_id"] == user_id else None
|
||||
|
||||
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
rows = [row for row in self._rows.values() if row["task_id"] == task_id and row["user_id"] == user_id and row["status"] in {"queued", "running"}]
|
||||
return dict(max(rows, key=lambda row: row["updated_at"])) if rows else None
|
||||
|
||||
async def claim_job(self, job_id: str, user_id: str) -> bool:
|
||||
row = self._rows.get(job_id)
|
||||
if row is None or row["user_id"] != user_id or row["status"] != "queued":
|
||||
return False
|
||||
row["status"] = "running"
|
||||
row["updated_at"] = _now()
|
||||
return True
|
||||
|
||||
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
|
||||
row = self._rows.get(job_id)
|
||||
if (
|
||||
row is None
|
||||
or row["user_id"] != user_id
|
||||
or row["status"] != "running"
|
||||
or row.get("updated_at") != expected_updated_at
|
||||
):
|
||||
return False
|
||||
row.update({"status": "queued", "phase": "queued", "report_markdown": "", "error_message": None})
|
||||
row["updated_at"] = _now()
|
||||
return True
|
||||
|
||||
async def transition_job(
|
||||
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
row = self._rows.get(job_id)
|
||||
if row is None or row["user_id"] != user_id or row["status"] not in expected_statuses:
|
||||
return None
|
||||
for key in (
|
||||
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
|
||||
"action", "instruction", "style", "target_length", "original_report",
|
||||
):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
if data.get("status") in {"completed", "failed", "cancelled"}:
|
||||
row["active_dedupe_key"] = None
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
row = self._rows.get(job_id)
|
||||
if row is None or row["user_id"] != user_id:
|
||||
return None
|
||||
for key in (
|
||||
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
|
||||
"action", "instruction", "style", "target_length", "original_report",
|
||||
):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
if data.get("status") in {"completed", "failed", "cancelled"}:
|
||||
row["active_dedupe_key"] = None
|
||||
row["updated_at"] = _now()
|
||||
return dict(row)
|
||||
|
||||
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
rows = [dict(row) for row in self._rows.values() if row["task_id"] == task_id and row["user_id"] == user_id]
|
||||
return sorted(rows, key=lambda row: row["updated_at"], reverse=True)[:limit]
|
||||
|
||||
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
rows = [dict(row) for row in self._rows.values() if row["status"] in {"queued", "running"}]
|
||||
return sorted(rows, key=lambda row: row["updated_at"])[:limit]
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
"""ORM rows for the independent enterprise-research workbench."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
|
||||
|
||||
|
||||
class EnterpriseResearchTaskRow(Base):
|
||||
"""A user-owned research task plus its bounded WeKnora evidence snapshot."""
|
||||
|
||||
__tablename__ = "enterprise_research_tasks"
|
||||
__table_args__ = (Index("ix_enterprise_research_tasks_owner_updated", "user_id", "updated_at"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
focus: Mapped[str] = mapped_column(Text, nullable=False, default="", server_default="")
|
||||
template_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
knowledge_base_ids: Mapped[list[str]] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||
queries: Mapped[list[str]] = mapped_column(PortableJSON(), nullable=False, default=list)
|
||||
# Nullable keeps upgrades safe: the generic schema reconciler can add this
|
||||
# field to a phase-one table without an unsafe MySQL TEXT default.
|
||||
selected_source_ids: Mapped[list[str] | None] = mapped_column(PortableJSON(), nullable=True, default=list)
|
||||
# A WeKnora result can contain a long document chunk. MySQL TEXT is only
|
||||
# 64 KiB, so evidence uses LONGTEXT through PortableLongText.
|
||||
sources_json: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="[]", server_default="[]")
|
||||
plan_markdown: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
report_markdown: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
report_summary: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
report_model_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
active_report_job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft", server_default="draft")
|
||||
warning: Mapped[str | None] = mapped_column(PortableLongText(), 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)
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobRow(Base):
|
||||
"""A report job whose plan/evidence are frozen when the user starts it."""
|
||||
|
||||
__tablename__ = "enterprise_research_report_jobs"
|
||||
__table_args__ = (
|
||||
Index("ix_enterprise_research_report_jobs_task_owner_updated", "task_id", "user_id", "updated_at"),
|
||||
UniqueConstraint("active_dedupe_key", name="uq_enterprise_research_report_jobs_active"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", server_default="queued")
|
||||
phase: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", server_default="queued")
|
||||
model_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
# Present only for queued/running rows. NULL terminal rows keep unlimited
|
||||
# history while this key rejects cross-process duplicate starts.
|
||||
active_dedupe_key: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
action: Mapped[str | None] = mapped_column(String(32), nullable=True, default="initial")
|
||||
instruction: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
style: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
target_length: Mapped[int | None] = mapped_column(nullable=True)
|
||||
plan_snapshot: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
|
||||
sources_json: Mapped[str] = mapped_column(PortableLongText(), nullable=False)
|
||||
original_report: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
report_markdown: Mapped[str] = mapped_column(PortableLongText(), nullable=False, default="", server_default="")
|
||||
report_summary: Mapped[str | None] = mapped_column(PortableLongText(), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(PortableLongText(), 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)
|
||||
)
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
"""SQLAlchemy store for enterprise-research tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.enterprise_research.base import EnterpriseResearchReportJobStore, EnterpriseResearchTaskStore
|
||||
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
|
||||
|
||||
|
||||
def _to_dict(row: EnterpriseResearchTaskRow) -> dict[str, Any]:
|
||||
try:
|
||||
sources = json.loads(row.sources_json or "[]")
|
||||
except (TypeError, ValueError):
|
||||
sources = []
|
||||
data: dict[str, Any] = {
|
||||
"id": row.id,
|
||||
"user_id": row.user_id,
|
||||
"subject": row.subject,
|
||||
"focus": row.focus,
|
||||
"template_id": row.template_id,
|
||||
"knowledge_base_ids": list(row.knowledge_base_ids or []),
|
||||
"queries": list(row.queries or []),
|
||||
"selected_source_ids": list(row.selected_source_ids or []),
|
||||
"sources": sources if isinstance(sources, list) else [],
|
||||
"plan_markdown": row.plan_markdown,
|
||||
"report_markdown": row.report_markdown,
|
||||
"report_summary": row.report_summary,
|
||||
"report_model_name": row.report_model_name,
|
||||
"active_report_job_id": row.active_report_job_id,
|
||||
"status": row.status,
|
||||
"warning": row.warning,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class EnterpriseResearchTaskRepository(EnterpriseResearchTaskStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_tasks(self, user_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(EnterpriseResearchTaskRow)
|
||||
.where(EnterpriseResearchTaskRow.user_id == user_id)
|
||||
.order_by(EnterpriseResearchTaskRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def get_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
stmt = select(EnterpriseResearchTaskRow).where(
|
||||
EnterpriseResearchTaskRow.id == task_id,
|
||||
EnterpriseResearchTaskRow.user_id == user_id,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
row = EnterpriseResearchTaskRow(
|
||||
id=str(data["id"]),
|
||||
user_id=str(data["user_id"]),
|
||||
subject=str(data["subject"]),
|
||||
focus=str(data.get("focus") or ""),
|
||||
template_id=str(data["template_id"]),
|
||||
knowledge_base_ids=list(data.get("knowledge_base_ids") or []),
|
||||
queries=list(data.get("queries") or []),
|
||||
selected_source_ids=list(data.get("selected_source_ids") or []),
|
||||
sources_json=json.dumps(list(data.get("sources") or []), ensure_ascii=False),
|
||||
plan_markdown=data.get("plan_markdown"),
|
||||
report_markdown=data.get("report_markdown"),
|
||||
report_summary=data.get("report_summary"),
|
||||
report_model_name=data.get("report_model_name"),
|
||||
active_report_job_id=data.get("active_report_job_id"),
|
||||
status=str(data.get("status") or "draft"),
|
||||
warning=data.get("warning"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
# Do not refresh after commit: a MySQL read/write split can route it
|
||||
# to a lagging replica. The flushed in-memory row is authoritative.
|
||||
await session.flush()
|
||||
payload = _to_dict(row)
|
||||
await session.commit()
|
||||
return payload
|
||||
|
||||
async def update_task(self, task_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
stmt = select(EnterpriseResearchTaskRow).where(
|
||||
EnterpriseResearchTaskRow.id == task_id,
|
||||
EnterpriseResearchTaskRow.user_id == user_id,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if "status" in data:
|
||||
row.status = str(data["status"])
|
||||
if "warning" in data:
|
||||
row.warning = data["warning"]
|
||||
if "sources" in data:
|
||||
row.sources_json = json.dumps(list(data["sources"] or []), ensure_ascii=False)
|
||||
if "selected_source_ids" in data:
|
||||
row.selected_source_ids = list(data["selected_source_ids"] or [])
|
||||
if "plan_markdown" in data:
|
||||
row.plan_markdown = data["plan_markdown"]
|
||||
for key in ("report_markdown", "report_summary", "report_model_name", "active_report_job_id"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
payload = _to_dict(row)
|
||||
await session.commit()
|
||||
return payload
|
||||
|
||||
async def delete_task(self, task_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
delete(EnterpriseResearchTaskRow).where(
|
||||
EnterpriseResearchTaskRow.id == task_id,
|
||||
EnterpriseResearchTaskRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
|
||||
def _job_to_dict(row: EnterpriseResearchReportJobRow) -> dict[str, Any]:
|
||||
try:
|
||||
sources = json.loads(row.sources_json or "[]")
|
||||
except (TypeError, ValueError):
|
||||
sources = []
|
||||
return {
|
||||
"id": row.id, "task_id": row.task_id, "user_id": row.user_id, "status": row.status,
|
||||
"phase": row.phase, "model_name": row.model_name, "action": row.action or "initial",
|
||||
"instruction": row.instruction, "style": row.style, "target_length": row.target_length,
|
||||
"plan_snapshot": row.plan_snapshot,
|
||||
"sources": sources if isinstance(sources, list) else [], "report_markdown": row.report_markdown,
|
||||
"original_report": row.original_report,
|
||||
"report_summary": row.report_summary, "error_message": row.error_message,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class EnterpriseResearchReportJobRepository(EnterpriseResearchReportJobStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create_job(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
status = str(data.get("status") or "queued")
|
||||
active_dedupe_key = (
|
||||
f"{data['user_id']}:{data['task_id']}" if status in {"queued", "running"} else None
|
||||
)
|
||||
row = EnterpriseResearchReportJobRow(
|
||||
id=str(data["id"]), task_id=str(data["task_id"]), user_id=str(data["user_id"]),
|
||||
status=status, phase=str(data.get("phase") or "queued"),
|
||||
model_name=data.get("model_name"), action=str(data.get("action") or "initial"),
|
||||
active_dedupe_key=active_dedupe_key,
|
||||
instruction=data.get("instruction"), style=data.get("style"), target_length=data.get("target_length"),
|
||||
plan_snapshot=str(data["plan_snapshot"]),
|
||||
sources_json=json.dumps(list(data.get("sources") or []), ensure_ascii=False),
|
||||
original_report=data.get("original_report"),
|
||||
report_markdown=str(data.get("report_markdown") or ""), report_summary=data.get("report_summary"),
|
||||
error_message=data.get("error_message"), created_at=now, updated_at=now,
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
try:
|
||||
await session.flush()
|
||||
payload = _job_to_dict(row)
|
||||
await session.commit()
|
||||
return payload
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
if not active_dedupe_key:
|
||||
raise
|
||||
existing = (await session.execute(
|
||||
select(EnterpriseResearchReportJobRow)
|
||||
.where(EnterpriseResearchReportJobRow.active_dedupe_key == active_dedupe_key)
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
raise
|
||||
return _job_to_dict(existing)
|
||||
|
||||
async def get_job(self, job_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
|
||||
EnterpriseResearchReportJobRow.id == job_id, EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
))).scalar_one_or_none()
|
||||
return _job_to_dict(row) if row is not None else None
|
||||
|
||||
async def get_active_for_task(self, task_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
|
||||
EnterpriseResearchReportJobRow.task_id == task_id,
|
||||
EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
EnterpriseResearchReportJobRow.status.in_(["queued", "running"]),
|
||||
).order_by(EnterpriseResearchReportJobRow.updated_at.desc()).limit(1))).scalar_one_or_none()
|
||||
return _job_to_dict(row) if row is not None else None
|
||||
|
||||
async def claim_job(self, job_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
update(EnterpriseResearchReportJobRow)
|
||||
.where(
|
||||
EnterpriseResearchReportJobRow.id == job_id,
|
||||
EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
EnterpriseResearchReportJobRow.status == "queued",
|
||||
)
|
||||
.values(status="running", updated_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def requeue_job(self, job_id: str, user_id: str, *, expected_updated_at: str | None) -> bool:
|
||||
if not expected_updated_at:
|
||||
return False
|
||||
try:
|
||||
expected = datetime.fromisoformat(expected_updated_at)
|
||||
except ValueError:
|
||||
return False
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
update(EnterpriseResearchReportJobRow)
|
||||
.where(
|
||||
EnterpriseResearchReportJobRow.id == job_id,
|
||||
EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
EnterpriseResearchReportJobRow.status == "running",
|
||||
EnterpriseResearchReportJobRow.updated_at == expected,
|
||||
)
|
||||
.values(
|
||||
status="queued",
|
||||
phase="queued",
|
||||
report_markdown="",
|
||||
error_message=None,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def transition_job(
|
||||
self, job_id: str, user_id: str, *, expected_statuses: set[str], data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
allowed = {
|
||||
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
|
||||
"action", "instruction", "style", "target_length", "original_report",
|
||||
}
|
||||
values = {key: value for key, value in data.items() if key in allowed}
|
||||
if data.get("status") in {"completed", "failed", "cancelled"}:
|
||||
values["active_dedupe_key"] = None
|
||||
values["updated_at"] = datetime.now(UTC)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(
|
||||
update(EnterpriseResearchReportJobRow)
|
||||
.where(
|
||||
EnterpriseResearchReportJobRow.id == job_id,
|
||||
EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
EnterpriseResearchReportJobRow.status.in_(expected_statuses),
|
||||
)
|
||||
.values(**values)
|
||||
)
|
||||
if not result.rowcount:
|
||||
await session.rollback()
|
||||
return None
|
||||
await session.commit()
|
||||
return await self.get_job(job_id, user_id)
|
||||
|
||||
async def update_job(self, job_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = (await session.execute(select(EnterpriseResearchReportJobRow).where(
|
||||
EnterpriseResearchReportJobRow.id == job_id, EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
))).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
for key in (
|
||||
"status", "phase", "report_markdown", "report_summary", "error_message", "model_name",
|
||||
"action", "instruction", "style", "target_length", "original_report",
|
||||
):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
if data.get("status") in {"completed", "failed", "cancelled"}:
|
||||
row.active_dedupe_key = None
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
payload = _job_to_dict(row)
|
||||
await session.commit()
|
||||
return payload
|
||||
|
||||
async def list_jobs(self, task_id: str, user_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(select(EnterpriseResearchReportJobRow).where(
|
||||
EnterpriseResearchReportJobRow.task_id == task_id, EnterpriseResearchReportJobRow.user_id == user_id,
|
||||
).order_by(EnterpriseResearchReportJobRow.updated_at.desc()).limit(limit))).scalars()
|
||||
return [_job_to_dict(row) for row in rows]
|
||||
|
||||
async def list_recoverable(self, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
async with self._sf() as session:
|
||||
rows = (await session.execute(
|
||||
select(EnterpriseResearchReportJobRow)
|
||||
.where(EnterpriseResearchReportJobRow.status.in_(["queued", "running"]))
|
||||
.order_by(EnterpriseResearchReportJobRow.updated_at.asc())
|
||||
.limit(limit)
|
||||
)).scalars()
|
||||
return [_job_to_dict(row) for row in rows]
|
||||
+3
@@ -46,6 +46,7 @@ from deerflow.persistence.deep_research_sessions.model import DeepResearchSessio
|
||||
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow
|
||||
from deerflow.persistence.document_rewrite_versions.model import DocumentRewriteVersionRow
|
||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||
from deerflow.persistence.enterprise_research.model import EnterpriseResearchReportJobRow, EnterpriseResearchTaskRow
|
||||
from deerflow.persistence.feedback.model import FeedbackRow
|
||||
from deerflow.persistence.intent_turns.model import IntentTurnRow
|
||||
from deerflow.persistence.light_apps.model import LightAppRow
|
||||
@@ -162,6 +163,8 @@ __all__ = [
|
||||
"TaskCopTaskRow",
|
||||
"TaskReportRow",
|
||||
"EmbedSessionRow",
|
||||
"EnterpriseResearchTaskRow",
|
||||
"EnterpriseResearchReportJobRow",
|
||||
"IntentTurnRow",
|
||||
"ThreadMetaRow",
|
||||
"ThreadShareRow",
|
||||
|
||||
@@ -412,7 +412,6 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
|
||||
@@ -426,7 +425,6 @@ def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths
|
||||
"common_questions": None,
|
||||
"template_name": None,
|
||||
"seat_skill_directive": None,
|
||||
"job_responsibilities": None,
|
||||
"llmwiki_knowledge_base_ids": None,
|
||||
}
|
||||
|
||||
@@ -499,31 +497,6 @@ def test_write_agent_config_omits_skills_when_none(isolated_paths) -> None:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Regression tests for owner-scoped enterprise-research task persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.enterprise_research import (
|
||||
EnterpriseResearchReportJobRepository,
|
||||
EnterpriseResearchTaskRepository,
|
||||
MemoryEnterpriseResearchReportJobStore,
|
||||
MemoryEnterpriseResearchTaskStore,
|
||||
make_enterprise_research_task_store,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'enterprise-research.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.enterprise_research.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return EnterpriseResearchTaskRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
|
||||
|
||||
async def _create(store, *, task_id: str, user_id: str = "u1"):
|
||||
return await store.create_task(
|
||||
{
|
||||
"id": task_id,
|
||||
"user_id": user_id,
|
||||
"subject": "新能源汽车供应链风险",
|
||||
"focus": "关键原材料",
|
||||
"template_id": "topic",
|
||||
"knowledge_base_ids": ["kb-1"],
|
||||
"queries": ["新能源汽车供应链风险", "关键原材料"],
|
||||
"status": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_evidence_roundtrip_and_owner_isolation(store: EnterpriseResearchTaskRepository):
|
||||
created = await _create(store, task_id="ert-1")
|
||||
assert created["sources"] == []
|
||||
assert created["status"] == "draft"
|
||||
|
||||
updated = await store.update_task(
|
||||
"ert-1",
|
||||
"u1",
|
||||
{
|
||||
"status": "ready",
|
||||
"selected_source_ids": ["chunk-1"],
|
||||
"plan_markdown": "# 供应链风险研究计划",
|
||||
"sources": [
|
||||
{
|
||||
"chunk_id": "chunk-1",
|
||||
"title": "供应链报告",
|
||||
"content": "证据内容",
|
||||
"knowledge_base_id": "kb-1",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["status"] == "ready"
|
||||
assert updated["sources"][0]["chunk_id"] == "chunk-1"
|
||||
assert updated["selected_source_ids"] == ["chunk-1"]
|
||||
assert updated["plan_markdown"] == "# 供应链风险研究计划"
|
||||
assert await store.get_task("ert-1", "u2") is None
|
||||
assert await store.update_task("ert-1", "u2", {"status": "failed"}) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_is_owner_scoped_and_delete_requires_owner(store: EnterpriseResearchTaskRepository):
|
||||
await _create(store, task_id="ert-u1", user_id="u1")
|
||||
await _create(store, task_id="ert-u2", user_id="u2")
|
||||
assert [task["id"] for task in await store.list_tasks("u1")] == ["ert-u1"]
|
||||
assert await store.delete_task("ert-u1", "u2") is False
|
||||
assert await store.delete_task("ert-u1", "u1") is True
|
||||
assert await store.get_task("ert-u1", "u1") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_store_matches_sql_contract():
|
||||
store = make_enterprise_research_task_store(None)
|
||||
assert isinstance(store, MemoryEnterpriseResearchTaskStore)
|
||||
await _create(store, task_id="memory-1")
|
||||
assert (await store.get_task("memory-1", "u1"))["subject"] == "新能源汽车供应链风险"
|
||||
assert await store.delete_task("memory-1", "u1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_job_freezes_sources_and_is_owner_scoped(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'enterprise-report-job.db'}")
|
||||
async with engine.begin() as conn:
|
||||
import deerflow.persistence.enterprise_research.model # noqa: F401
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
jobs = EnterpriseResearchReportJobRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||
created = await jobs.create_job(
|
||||
{
|
||||
"id": "erj-1", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 计划",
|
||||
"sources": [{"chunk_id": "source-1", "content": "冻结证据"}],
|
||||
"action": "rewrite", "instruction": "优化结构", "original_report": "# 原报告",
|
||||
}
|
||||
)
|
||||
assert created["status"] == "queued"
|
||||
assert created["sources"][0]["content"] == "冻结证据"
|
||||
assert created["action"] == "rewrite"
|
||||
assert created["original_report"] == "# 原报告"
|
||||
deduplicated = await jobs.create_job(
|
||||
{
|
||||
"id": "erj-duplicate", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 其他计划",
|
||||
"sources": [], "action": "initial",
|
||||
}
|
||||
)
|
||||
assert deduplicated["id"] == "erj-1"
|
||||
assert await jobs.get_job("erj-1", "u2") is None
|
||||
assert (await jobs.get_active_for_task("ert-1", "u1"))["id"] == "erj-1"
|
||||
assert await jobs.claim_job("erj-1", "u1") is True
|
||||
assert await jobs.claim_job("erj-1", "u1") is False
|
||||
running = (await jobs.list_recoverable())[0]
|
||||
assert running["status"] == "running"
|
||||
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at="stale-snapshot") is False
|
||||
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at=running["updated_at"]) is True
|
||||
assert await jobs.requeue_job("erj-1", "u1", expected_updated_at=running["updated_at"]) is False
|
||||
assert await jobs.claim_job("erj-1", "u1") is True
|
||||
completed = await jobs.transition_job(
|
||||
"erj-1", "u1", expected_statuses={"running"},
|
||||
data={"status": "completed", "phase": "completed", "report_markdown": "# 报告"},
|
||||
)
|
||||
assert completed is not None and completed["report_markdown"] == "# 报告"
|
||||
assert await jobs.transition_job(
|
||||
"erj-1", "u1", expected_statuses={"running"}, data={"status": "cancelled"}
|
||||
) is None
|
||||
assert await jobs.get_active_for_task("ert-1", "u1") is None
|
||||
next_job = await jobs.create_job(
|
||||
{"id": "erj-2", "task_id": "ert-1", "user_id": "u1", "plan_snapshot": "# 新计划", "sources": []}
|
||||
)
|
||||
assert next_job["id"] == "erj-2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_report_job_store_has_matching_owner_contract():
|
||||
jobs = MemoryEnterpriseResearchReportJobStore()
|
||||
await jobs.create_job({"id": "memory-job", "task_id": "task", "user_id": "u1", "plan_snapshot": "x"})
|
||||
assert await jobs.get_job("memory-job", "u2") is None
|
||||
assert (await jobs.list_jobs("task", "u1"))[0]["id"] == "memory-job"
|
||||
assert await jobs.claim_job("memory-job", "u1") is True
|
||||
assert await jobs.claim_job("memory-job", "u1") is False
|
||||
running = await jobs.get_job("memory-job", "u1")
|
||||
assert running is not None
|
||||
assert await jobs.requeue_job("memory-job", "u1", expected_updated_at=running["updated_at"]) is True
|
||||
assert await jobs.claim_job("memory-job", "u1") is True
|
||||
completed = await jobs.transition_job(
|
||||
"memory-job", "u1", expected_statuses={"running"}, data={"status": "completed", "phase": "completed"}
|
||||
)
|
||||
assert completed is not None and completed["status"] == "completed"
|
||||
|
||||
|
||||
def test_evidence_snapshot_deduplicates_and_never_exposes_unmapped_remote_bases():
|
||||
from app.gateway.routers.enterprise_research import _dedupe_evidence
|
||||
|
||||
mappings = {"remote-kb-1": {"id": "local-kb-1", "name": "用户可见知识库", "weknora_id": "remote-kb-1"}}
|
||||
evidence = _dedupe_evidence(
|
||||
[
|
||||
{"chunk_id": "chunk-1", "content": "第一段", "knowledge_base_id": "remote-kb-1", "title": "文档 A"},
|
||||
{"chunk_id": "chunk-1", "content": "重复段", "knowledge_base_id": "remote-kb-1", "title": "文档 A"},
|
||||
{"chunk_id": "private-chunk", "content": "不应显示", "knowledge_base_id": "other-user-kb", "title": "私有文档"},
|
||||
],
|
||||
mappings,
|
||||
)
|
||||
assert len(evidence) == 1
|
||||
assert evidence[0]["knowledge_base_id"] == "local-kb-1"
|
||||
assert "other-user-kb" not in str(evidence)
|
||||
|
||||
|
||||
def test_report_executor_uses_distinct_initial_rewrite_and_followup_prompts():
|
||||
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
|
||||
|
||||
base = {
|
||||
"plan_snapshot": "# 计划",
|
||||
"sources": [{"chunk_id": "source-1", "title": "内部资料", "content": "证据正文"}],
|
||||
"original_report": "# 原报告\n原始正文",
|
||||
"instruction": "压缩重复段落",
|
||||
"target_length": 3_000,
|
||||
"style": "analytical",
|
||||
}
|
||||
initial = EnterpriseResearchReportExecutor._messages({**base, "action": "initial"})
|
||||
rewrite = EnterpriseResearchReportExecutor._messages({**base, "action": "rewrite"})
|
||||
followup = EnterpriseResearchReportExecutor._messages({**base, "action": "followup"})
|
||||
|
||||
assert "完整 Markdown 报告" in initial[0]["content"]
|
||||
assert "原报告" not in initial[1]["content"]
|
||||
assert "全文改写" in rewrite[1]["content"] and "# 原报告" in rewrite[1]["content"]
|
||||
assert "不要重写整篇报告" in followup[0]["content"]
|
||||
assert "压缩重复段落" in followup[1]["content"]
|
||||
summary = EnterpriseResearchReportExecutor._summary(
|
||||
"# 新报告\n\n新正文", 2, "rewrite", original_report="# 旧报告\n\n旧正文", instruction="调整结构"
|
||||
)
|
||||
assert "原文" in summary and "标题节点 1→1" in summary and "调整结构" in summary
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_recovery_requeues_exact_running_snapshot(monkeypatch):
|
||||
from app.gateway.enterprise_research_report_executor import EnterpriseResearchReportExecutor
|
||||
|
||||
tasks = MemoryEnterpriseResearchTaskStore()
|
||||
jobs = MemoryEnterpriseResearchReportJobStore()
|
||||
await jobs.create_job({"id": "recover-job", "task_id": "task", "user_id": "u1", "plan_snapshot": "# 计划"})
|
||||
assert await jobs.claim_job("recover-job", "u1") is True
|
||||
executor = EnterpriseResearchReportExecutor(tasks, jobs)
|
||||
started: list[tuple[str, str]] = []
|
||||
|
||||
async def remember(job_id: str, user_id: str) -> None:
|
||||
started.append((job_id, user_id))
|
||||
|
||||
monkeypatch.setattr(executor, "ensure_started", remember)
|
||||
await executor.recover_pending()
|
||||
|
||||
recovered = await jobs.get_job("recover-job", "u1")
|
||||
assert recovered is not None and recovered["status"] == "queued"
|
||||
assert started == [("recover-job", "u1")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enterprise_research_http_plan_job_dedupe_and_cancel(monkeypatch):
|
||||
import app.gateway.routers.enterprise_research as enterprise_router
|
||||
|
||||
tasks = MemoryEnterpriseResearchTaskStore()
|
||||
jobs = MemoryEnterpriseResearchReportJobStore()
|
||||
|
||||
class NoopExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.started: list[tuple[str, str]] = []
|
||||
|
||||
async def ensure_started(self, job_id: str, user_id: str) -> None:
|
||||
self.started.append((job_id, user_id))
|
||||
|
||||
async def cancel(self, job_id: str, user_id: str):
|
||||
job = await jobs.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
cancelled = await jobs.transition_job(
|
||||
job_id, user_id, expected_statuses={"queued", "running"},
|
||||
data={"status": "cancelled", "phase": "cancelled"},
|
||||
)
|
||||
await tasks.update_task(job["task_id"], user_id, {"active_report_job_id": None})
|
||||
return cancelled
|
||||
|
||||
async def anonymous_actor(_request):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(enterprise_router, "get_optional_user_from_request", anonymous_actor)
|
||||
app = FastAPI()
|
||||
app.include_router(enterprise_router.router)
|
||||
app.state.enterprise_research_task_store = tasks
|
||||
app.state.enterprise_research_report_job_store = jobs
|
||||
app.state.enterprise_research_report_executor = NoopExecutor()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/enterprise-research/tasks",
|
||||
json={"subject": "企业风险", "queries": ["企业风险"], "knowledge_base_ids": []},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
task_id = created.json()["id"]
|
||||
await tasks.update_task(
|
||||
task_id, "default",
|
||||
{
|
||||
"status": "ready", "selected_source_ids": ["source-1"],
|
||||
"sources": [{"chunk_id": "source-1", "title": "内部材料", "content": "已核验事实"}],
|
||||
},
|
||||
)
|
||||
plan = await client.post(f"/api/enterprise-research/tasks/{task_id}/plan/draft")
|
||||
assert plan.status_code == 200 and plan.json()["plan_markdown"]
|
||||
saved = await client.put(
|
||||
f"/api/enterprise-research/tasks/{task_id}/plan",
|
||||
json={"plan_markdown": "# 企业风险报告", "selected_source_ids": ["source-1"]},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
first = await client.post(
|
||||
f"/api/enterprise-research/tasks/{task_id}/report-jobs",
|
||||
json={"action": "initial", "style": "formal", "target_length": 2_000},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
duplicate = await client.post(
|
||||
f"/api/enterprise-research/tasks/{task_id}/report-jobs",
|
||||
json={"action": "initial"},
|
||||
)
|
||||
assert duplicate.status_code == 201 and duplicate.json()["id"] == first.json()["id"]
|
||||
cancelled = await client.post(
|
||||
f"/api/enterprise-research/tasks/{task_id}/report-jobs/{first.json()['id']}/cancel"
|
||||
)
|
||||
assert cancelled.status_code == 200 and cancelled.json()["status"] == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_stream_commit_separates_reasoning_and_followup(monkeypatch):
|
||||
import app.gateway.enterprise_research_report_executor as executor_module
|
||||
|
||||
tasks = MemoryEnterpriseResearchTaskStore()
|
||||
jobs = MemoryEnterpriseResearchReportJobStore()
|
||||
await _create(tasks, task_id="stream-task")
|
||||
await tasks.update_task(
|
||||
"stream-task", "u1",
|
||||
{"plan_markdown": "# 研究计划", "report_markdown": "# 旧报告", "report_summary": "旧摘要"},
|
||||
)
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self, _config) -> None:
|
||||
pass
|
||||
|
||||
async def stream_complete(self, *, messages, on_delta, on_reasoning, **_kwargs):
|
||||
is_followup = "不要重写整篇报告" in messages[0]["content"]
|
||||
text = "追问回答" if is_followup else "# 新报告\n正文 [资料:source-1]"
|
||||
await on_reasoning("模型内部思考")
|
||||
for delta in (text[:4], text[4:]):
|
||||
await on_delta(delta)
|
||||
return SimpleNamespace(text=text, model="fake-model")
|
||||
|
||||
monkeypatch.setattr(executor_module, "DeerFlowCompletionBackend", FakeBackend)
|
||||
executor = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
|
||||
await jobs.create_job(
|
||||
{
|
||||
"id": "initial-job", "task_id": "stream-task", "user_id": "u1", "plan_snapshot": "# 研究计划",
|
||||
"sources": [{"chunk_id": "source-1", "content": "证据"}], "action": "initial",
|
||||
}
|
||||
)
|
||||
await executor._run("initial-job", "u1")
|
||||
completed = await jobs.get_job("initial-job", "u1")
|
||||
task = await tasks.get_task("stream-task", "u1")
|
||||
assert completed is not None and completed["status"] == "completed"
|
||||
assert completed["report_markdown"] == "# 新报告\n正文 [资料:source-1]"
|
||||
assert "模型内部思考" not in completed["report_markdown"]
|
||||
assert task is not None and task["report_markdown"] == completed["report_markdown"]
|
||||
|
||||
await jobs.create_job(
|
||||
{
|
||||
"id": "followup-job", "task_id": "stream-task", "user_id": "u1", "plan_snapshot": "# 研究计划",
|
||||
"sources": [{"chunk_id": "source-1", "content": "证据"}], "action": "followup",
|
||||
"instruction": "结论是什么", "original_report": completed["report_markdown"],
|
||||
}
|
||||
)
|
||||
await executor._run("followup-job", "u1")
|
||||
followup = await jobs.get_job("followup-job", "u1")
|
||||
unchanged = await tasks.get_task("stream-task", "u1")
|
||||
assert followup is not None and followup["report_markdown"] == "追问回答"
|
||||
assert unchanged is not None and unchanged["report_markdown"] == completed["report_markdown"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_process_cancel_cannot_be_overwritten_by_late_model_result(monkeypatch):
|
||||
import app.gateway.enterprise_research_report_executor as executor_module
|
||||
|
||||
tasks = MemoryEnterpriseResearchTaskStore()
|
||||
jobs = MemoryEnterpriseResearchReportJobStore()
|
||||
await _create(tasks, task_id="cancel-task")
|
||||
await tasks.update_task("cancel-task", "u1", {"report_markdown": "# 已保存报告"})
|
||||
await jobs.create_job(
|
||||
{"id": "cancel-job", "task_id": "cancel-task", "user_id": "u1", "plan_snapshot": "# 计划"}
|
||||
)
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
class SlowBackend:
|
||||
def __init__(self, _config) -> None:
|
||||
pass
|
||||
|
||||
async def stream_complete(self, *, on_delta, **_kwargs):
|
||||
started.set()
|
||||
await release.wait()
|
||||
await on_delta("# 不应提交的迟到结果")
|
||||
return SimpleNamespace(text="# 不应提交的迟到结果", model="fake-model")
|
||||
|
||||
monkeypatch.setattr(executor_module, "DeerFlowCompletionBackend", SlowBackend)
|
||||
writer = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
|
||||
remote_gateway = executor_module.EnterpriseResearchReportExecutor(tasks, jobs)
|
||||
running = asyncio.create_task(writer._run("cancel-job", "u1"))
|
||||
await started.wait()
|
||||
cancelled = await remote_gateway.cancel("cancel-job", "u1")
|
||||
assert cancelled is not None and cancelled["status"] == "cancelled"
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await running
|
||||
final_job = await jobs.get_job("cancel-job", "u1")
|
||||
final_task = await tasks.get_task("cancel-task", "u1")
|
||||
assert final_job is not None and final_job["status"] == "cancelled"
|
||||
assert final_task is not None and final_task["report_markdown"] == "# 已保存报告"
|
||||
@@ -6,6 +6,7 @@ from types import SimpleNamespace
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from starlette.datastructures import Headers, QueryParams
|
||||
from starlette.requests import Request
|
||||
|
||||
@@ -19,6 +20,8 @@ from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||
from deerflow.persistence.llmwiki import MemoryLlmWikiStore
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import (
|
||||
_sanitize_conversation_deposit_value as sanitize_tool_deposit_value,
|
||||
)
|
||||
from deerflow.tools.builtins.llmwiki_search_tool import (
|
||||
selected_knowledge_base_ids,
|
||||
)
|
||||
|
||||
@@ -31,11 +34,9 @@ def _request(
|
||||
request_headers: dict[str, str] | None = None,
|
||||
root_path: str = "",
|
||||
) -> Request:
|
||||
headers: list[tuple[bytes, bytes]] = [
|
||||
(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()
|
||||
]
|
||||
headers: list[tuple[bytes, bytes]] = [(key.lower().encode(), value.encode()) for key, value in (request_headers or {}).items()]
|
||||
if cookies:
|
||||
headers.append(("cookie".encode(), "; ".join(f"{key}={value}" for key, value in cookies.items()).encode()))
|
||||
headers.append((b"cookie", "; ".join(f"{key}={value}" for key, value in cookies.items()).encode()))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
@@ -173,9 +174,7 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
||||
cookies = {"deerflow_weknora_embed": raw_cookie}
|
||||
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/deerflow/platform/knowledge-bases/kb", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/platform/knowledge-bases/kb", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/assets/index.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/assets/index.js", cookies=cookies))
|
||||
for static_path in (
|
||||
@@ -191,26 +190,14 @@ def test_weknora_embed_cookie_bypasses_only_proxy_paths() -> None:
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(request)
|
||||
assert csrf_middleware.is_weknora_embed_proxy_request(request)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/config.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/tdesign-icons/0.4.1/fonts/index.js", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/deerflow/api/v1/knowledge-search", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies)
|
||||
)
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/v1/knowledge-search", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies))
|
||||
assert auth_middleware._is_weknora_embed_proxy_request(_request("/deerflow/api/llmwiki/weknora-embed/api/v1/auth/me", cookies=cookies))
|
||||
assert csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(
|
||||
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
||||
)
|
||||
assert not csrf_middleware.is_weknora_embed_proxy_request(
|
||||
_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"})
|
||||
)
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}))
|
||||
assert not csrf_middleware.is_weknora_embed_proxy_request(_request("/api/v1/knowledge-search", cookies={"deerflow_weknora_embed": "signed"}))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/v1/auth/me", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/api/models", cookies=cookies))
|
||||
assert not auth_middleware._is_weknora_embed_proxy_request(_request("/platform/knowledge-bases/kb"))
|
||||
@@ -227,6 +214,12 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
|
||||
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||
|
||||
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
||||
return [
|
||||
{"id": "local-kb", "weknora_id": "remote-kb"},
|
||||
{"id": "public-kb", "weknora_id": "other-public-kb"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
||||
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
@@ -246,11 +239,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
|
||||
assert "deerflow_weknora_embed=" in cookie
|
||||
assert "Secure" in cookie
|
||||
assert "SameSite=none" in cookie
|
||||
set_cookie_headers = [
|
||||
value.decode("latin-1")
|
||||
for key, value in response.raw_headers
|
||||
if key.lower() == b"set-cookie"
|
||||
]
|
||||
set_cookie_headers = [value.decode("latin-1") for key, value in response.raw_headers if key.lower() == b"set-cookie"]
|
||||
assert any("Path=/" in value for value in set_cookie_headers)
|
||||
assert any("Path=/deerflow" in value for value in set_cookie_headers)
|
||||
raw = cookie.split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
||||
@@ -258,6 +247,7 @@ async def test_weknora_session_endpoint_sets_cookie_and_returns_platform_path(mo
|
||||
assert ctx.mapping_id == "local-kb"
|
||||
assert ctx.weknora_id == "remote-kb"
|
||||
assert ctx.can_write is True
|
||||
assert ctx.allowed_weknora_ids == ("remote-kb", "other-public-kb")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -271,6 +261,9 @@ async def test_weknora_session_endpoint_preserves_reverse_proxy_prefix(monkeypat
|
||||
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||
|
||||
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
||||
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
@@ -301,6 +294,9 @@ async def test_weknora_session_endpoint_uses_backend_prefix_when_frontend_has_di
|
||||
async def get_authorized(self, *_: object, **__: object) -> dict[str, str]:
|
||||
return {"id": "local-kb", "weknora_id": "remote-kb"}
|
||||
|
||||
async def list_visible(self, *_: object, **__: object) -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_runtime_for_iframe_proxy", lambda _: object())
|
||||
monkeypatch.setattr(llmwiki_router, "_authorized_mapping", authorized_mapping)
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
@@ -1089,15 +1085,9 @@ def test_weknora_iframe_cookie_is_signed_and_short_lived() -> None:
|
||||
def test_weknora_iframe_proxy_is_fallback_after_deerflow_api_routes() -> None:
|
||||
assert _first_gateway_match("POST", "/api/v1/auth/login/username") == "/api/v1/auth/login/username"
|
||||
assert _first_gateway_match("POST", "/api/v1/knowledge-search") == "/api/v1/{proxied_path:path}"
|
||||
assert (
|
||||
_first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me")
|
||||
== "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
||||
)
|
||||
assert _first_gateway_match("GET", "/api/llmwiki/weknora-embed/api/v1/auth/me") == "/api/llmwiki/weknora-embed/api/v1/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/platform/knowledge-bases/remote-kb") == "/platform/{proxied_path:path}"
|
||||
assert (
|
||||
_first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb")
|
||||
== "/deerflow/platform/{proxied_path:path}"
|
||||
)
|
||||
assert _first_gateway_match("GET", "/deerflow/platform/knowledge-bases/remote-kb") == "/deerflow/platform/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/assets/index.js") == "/deerflow/assets/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/js/index.js") == "/deerflow/{proxied_path:path}"
|
||||
assert _first_gateway_match("GET", "/deerflow/css/index.css") == "/deerflow/{proxied_path:path}"
|
||||
@@ -1177,21 +1167,13 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_
|
||||
if path == "/platform/knowledge-bases/remote-kb":
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'<html><head><script src="/assets/main.js"></script>'
|
||||
'<link rel="stylesheet" href="css/app.css">'
|
||||
'<script src="/config.js"></script></head><body></body></html>'
|
||||
),
|
||||
text=('<html><head><script src="/assets/main.js"></script><link rel="stylesheet" href="css/app.css"><script src="/config.js"></script></head><body></body></html>'),
|
||||
headers={"content-type": "text/html; charset=utf-8"},
|
||||
)
|
||||
if path == "/assets/main.js":
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=(
|
||||
'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=['
|
||||
'"assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);'
|
||||
'const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'
|
||||
),
|
||||
text=('const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/lazy.css","assets/lazy.js"])))=>i.map(i=>d[i]);const chunk="/static/chunk.js",matcher=/platform\\//;fetch("/api/v1/auth/me")'),
|
||||
headers={"content-type": "application/javascript"},
|
||||
)
|
||||
if path == "/css/app.css":
|
||||
@@ -1246,7 +1228,7 @@ async def test_prefixed_weknora_proxy_serves_page_static_assets_and_api_without_
|
||||
assert '"deerflow/assets/lazy.css"' in script.text
|
||||
assert '"deerflow/assets/lazy.js"' in script.text
|
||||
assert '"/deerflow/assets/lazy.css"' not in script.text
|
||||
assert 'matcher=/platform\\//' in script.text
|
||||
assert "matcher=/platform\\//" in script.text
|
||||
assert 'fetch("/deerflow/api/v1/auth/me")' in script.text
|
||||
|
||||
stylesheet = await client.get("/deerflow/css/app.css")
|
||||
@@ -1280,6 +1262,7 @@ def test_weknora_iframe_hides_current_outer_sidebar_shell() -> None:
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
||||
)
|
||||
injection = llmwiki_router._weknora_embed_injection(
|
||||
allowed_path="/platform/knowledge-bases/remote-kb",
|
||||
@@ -1302,6 +1285,7 @@ def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None:
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
||||
)
|
||||
html = b'<html><head><script src="/assets/index.js"></script></head><body>fetch("/api/v1/auth/me")</body></html>'
|
||||
|
||||
@@ -1318,9 +1302,12 @@ def test_weknora_iframe_html_rewrites_public_prefix_paths() -> None:
|
||||
assert 'fetch("/deerflow/api/v1/auth/me")' in injected
|
||||
assert '"apiPrefix":"/deerflow/api/llmwiki/weknora-embed"' in injected
|
||||
assert '"allowedPath":"/deerflow/platform/knowledge-bases/remote-kb"' in injected
|
||||
assert '"allowedWeKnoraIds":["remote-kb","other-visible-kb"]' in injected
|
||||
assert ".breadcrumb-link.dropdown" not in injected
|
||||
assert "reloadForKnowledgeBaseSwitch" in injected
|
||||
|
||||
|
||||
def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
def test_weknora_iframe_filters_kb_list_to_signed_visible_mappings() -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
ctx = llmwiki_router.WeKnoraEmbedContext(
|
||||
@@ -1330,6 +1317,7 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
||||
)
|
||||
response = httpx.Response(
|
||||
200,
|
||||
@@ -1337,6 +1325,7 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
"success": True,
|
||||
"data": [
|
||||
{"id": "remote-kb", "name": "Allowed"},
|
||||
{"id": "other-visible-kb", "name": "Also allowed"},
|
||||
{"id": "other-kb", "name": "Blocked"},
|
||||
],
|
||||
},
|
||||
@@ -1346,7 +1335,112 @@ def test_weknora_iframe_filters_kb_list_to_current_mapping() -> None:
|
||||
|
||||
assert filtered is not None
|
||||
payload = json.loads(filtered)
|
||||
assert payload["data"] == [{"id": "remote-kb", "name": "Allowed"}]
|
||||
assert payload["data"] == [
|
||||
{"id": "remote-kb", "name": "Allowed"},
|
||||
{"id": "other-visible-kb", "name": "Also allowed"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_iframe_switch_rechecks_selected_mapping_permissions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
class Store:
|
||||
async def get_by_weknora_id(self, weknora_id: str) -> dict[str, str] | None:
|
||||
if weknora_id == "other-visible-kb":
|
||||
return {"id": "public-kb", "weknora_id": weknora_id, "owner_user_id": "owner-b"}
|
||||
return None
|
||||
|
||||
async def get_authorized(self, mapping_id: str, _: str, *, write: bool, **__: object) -> dict[str, str] | None:
|
||||
if mapping_id != "public-kb":
|
||||
return None
|
||||
if write:
|
||||
return None
|
||||
return {"id": "public-kb", "weknora_id": "other-visible-kb", "owner_user_id": "owner-b"}
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_store", lambda _: Store())
|
||||
original = llmwiki_router.WeKnoraEmbedContext(
|
||||
mapping_id="local-kb",
|
||||
weknora_id="remote-kb",
|
||||
user_id="user-a",
|
||||
is_admin=False,
|
||||
can_write=True,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
||||
)
|
||||
|
||||
selected = await llmwiki_router._switch_embed_context(
|
||||
_request("/platform/knowledge-bases/other-visible-kb"),
|
||||
original,
|
||||
"other-visible-kb",
|
||||
)
|
||||
|
||||
assert selected.mapping_id == "public-kb"
|
||||
assert selected.weknora_id == "other-visible-kb"
|
||||
assert selected.can_write is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weknora_platform_proxy_reissues_cookie_for_native_kb_switch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.gateway.routers import llmwiki as llmwiki_router
|
||||
|
||||
current = llmwiki_router.WeKnoraEmbedContext(
|
||||
mapping_id="local-kb",
|
||||
weknora_id="remote-kb",
|
||||
user_id="user-a",
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=("remote-kb", "other-visible-kb"),
|
||||
)
|
||||
selected = llmwiki_router.WeKnoraEmbedContext(
|
||||
mapping_id="public-kb",
|
||||
weknora_id="other-visible-kb",
|
||||
user_id="user-a",
|
||||
is_admin=False,
|
||||
can_write=False,
|
||||
exp=4_102_444_800,
|
||||
allowed_weknora_ids=current.allowed_weknora_ids,
|
||||
)
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
async def read_context(_: Request) -> object:
|
||||
return current
|
||||
|
||||
async def switch_context(_: Request, ctx: object, weknora_id: str) -> object:
|
||||
seen["current"] = ctx
|
||||
seen["target"] = weknora_id
|
||||
return selected
|
||||
|
||||
async def proxy_request(*_: object, **kwargs: object) -> Response:
|
||||
seen.update(kwargs)
|
||||
return Response("ok")
|
||||
|
||||
monkeypatch.setattr(llmwiki_router, "_read_embed_context", read_context)
|
||||
monkeypatch.setattr(llmwiki_router, "_switch_embed_context", switch_context)
|
||||
monkeypatch.setattr(
|
||||
llmwiki_router,
|
||||
"_runtime_for_iframe_proxy",
|
||||
lambda _: SimpleNamespace(web_base_url="http://weknora-web"),
|
||||
)
|
||||
monkeypatch.setattr(llmwiki_router, "_proxy_weknora_request", proxy_request)
|
||||
|
||||
response = await llmwiki_router.proxy_weknora_platform_page(
|
||||
_request("/platform/knowledge-bases/other-visible-kb", request_headers={"X-Forwarded-Proto": "https"}),
|
||||
"knowledge-bases/other-visible-kb",
|
||||
)
|
||||
|
||||
assert seen["current"] == current
|
||||
assert seen["target"] == "other-visible-kb"
|
||||
assert seen["upstream_path"] == "/platform/knowledge-bases/other-visible-kb"
|
||||
raw_cookie = response.headers["set-cookie"].split("deerflow_weknora_embed=", 1)[1].split(";", 1)[0]
|
||||
switched_context = llmwiki_router._verify_embed_cookie(raw_cookie)
|
||||
assert switched_context.mapping_id == "public-kb"
|
||||
assert switched_context.weknora_id == "other-visible-kb"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1365,7 +1459,7 @@ async def test_conversation_deposit_iframe_sanitizes_legacy_brand(monkeypatch: p
|
||||
def __init__(self, **_: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "FakeAsyncClient":
|
||||
async def __aenter__(self) -> FakeAsyncClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
|
||||
Reference in new issue
Block a user