代码提交
This commit is contained in:
1 parent
27e6b0b23e
commit
d7ad0624b7
34 files changed
+2186
-279
No files matched your search
@@ -5,7 +5,7 @@
|
||||
VITE_USE_REAL_DATA=true
|
||||
|
||||
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
|
||||
VITE_MOCK_COP_TASK_DETAIL=flase
|
||||
VITE_MOCK_COP_TASK_DETAIL=true
|
||||
|
||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||
VITE_MOCK_SQ_REPORT=true
|
||||
|
||||
@@ -85,8 +85,8 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
|
||||
VITE_MOCK_COP_TASK_DETAIL: "false",
|
||||
|
||||
// 大屏绘制 selectSqReport 是否使用本地 mock:false=真实接口;true=本地 mock。
|
||||
VITE_MOCK_SQ_REPORT: "false",
|
||||
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
|
||||
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
|
||||
|
||||
// 静态站点模式开关;一般部署业务系统时保持 false。
|
||||
VITE_STATIC_WEBSITE_ONLY: "false",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, FileTextIcon, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
@@ -51,6 +53,9 @@ export function SourceCard({
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const cleanupWords =
|
||||
@@ -73,6 +78,44 @@ export function SourceCard({
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !previewTarget) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setContext(null);
|
||||
setContextError(null);
|
||||
setContextLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||
chunk_id: previewTarget.chunkId,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||
throw new Error(readPreviewError(payload, response.status));
|
||||
}
|
||||
if (!cancelled) {
|
||||
setContext(payload.data);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setContextError(error instanceof Error ? error.message : "无法加载原文上下文");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setContextLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,7 +157,14 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
previewTarget ? (
|
||||
<span className="text-primary bg-primary/10 mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
查看原文
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -141,7 +191,7 @@ export function SourceCard({
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
{source.url && !previewTarget && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
@@ -155,7 +205,13 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{paragraphs.length > 0 && (
|
||||
{previewTarget ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
error={contextError}
|
||||
/>
|
||||
) : paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
@@ -163,8 +219,8 @@ export function SourceCard({
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
@@ -175,6 +231,118 @@ export function SourceCard({
|
||||
);
|
||||
}
|
||||
|
||||
type WeKnoraPreviewTarget = {
|
||||
knowledgeBaseId: string;
|
||||
chunkId: string;
|
||||
};
|
||||
|
||||
type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
focus_chunk_id: string;
|
||||
chunks: Array<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
content: string;
|
||||
is_focus: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
return null;
|
||||
}
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||
}
|
||||
|
||||
function isWeKnoraSourceContextResponse(
|
||||
value: unknown,
|
||||
): value is { success: true; data: WeKnoraSourceContext } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const data = (value as { data?: unknown }).data;
|
||||
return (
|
||||
(value as { success?: unknown }).success === true &&
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
Array.isArray((data as WeKnoraSourceContext).chunks)
|
||||
);
|
||||
}
|
||||
|
||||
function readPreviewError(payload: unknown, status: number): string {
|
||||
if (payload && typeof payload === "object") {
|
||||
const detail = (payload as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
return status === 404 ? "原文已不可用" : "无法加载原文上下文";
|
||||
}
|
||||
|
||||
function WeKnoraSourceContextPanel({
|
||||
context,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
context: WeKnoraSourceContext | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-36 items-center justify-center gap-2 text-sm">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在加载原文上下文…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-destructive/30 bg-destructive/5 text-destructive rounded-lg border p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
|
||||
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
|
||||
{context.document_title && <span> · {context.document_title}</span>}
|
||||
</div>
|
||||
{context.chunks.map((chunk) => (
|
||||
<section
|
||||
key={chunk.id}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-sm leading-relaxed",
|
||||
chunk.is_focus
|
||||
? "border-primary/50 bg-primary/5 shadow-sm"
|
||||
: "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
<div className="text-muted-foreground mb-2 flex items-center gap-2 text-xs">
|
||||
<span>分段 {typeof chunk.chunk_index === "number" ? chunk.chunk_index + 1 : "-"}</span>
|
||||
{chunk.is_focus && (
|
||||
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium">
|
||||
检索命中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{chunk.content}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
|
||||
@@ -1,101 +1,19 @@
|
||||
/**
|
||||
* 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。
|
||||
* 大屏绘制智能体的任务输入适配层。
|
||||
*
|
||||
* 设计意图:深链 `?taskId=` 模式下,后端提供「研判情报报告」接口
|
||||
* GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`
|
||||
* 返回该任务的多类情报内容;本页把它**提取成干净的结构化 JSON** 交给智能体分析
|
||||
* → 产出 report-json → 绘制大屏(react / html)。
|
||||
*
|
||||
* 接口返回结构:`{ state, msg, data: [{ id, taskId, ..., contentJson, categoryType }] }`,
|
||||
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
|
||||
* 本模块 `extractSqReport` 把每条的 `contentJson` 反序列化、按 `categoryType` 归类,
|
||||
* 拼成 `{ taskId, categories: { 敌情关键事件: [...], 决心建议: {...}, ... } }` 喂给智能体。
|
||||
*
|
||||
* ⚠️ 真实接口现在还**请求不到**:通过 `VITE_MOCK_SQ_REPORT=true`(见 `utils/request.ts`)
|
||||
* 走后端给的样例假数据(`sq-report-mock.json`),机制与 cop-task-detail 假数据一致。
|
||||
* 联调通过后把该 env 置 false 即走真实接口;接口/数据若有变只需改 `extractSqReport`。
|
||||
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
|
||||
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
|
||||
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
|
||||
* 结构化输入。
|
||||
*/
|
||||
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
|
||||
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
|
||||
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
|
||||
import { getTaskReport } from "@/strategy-components/api/task-reports";
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
|
||||
|
||||
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
|
||||
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
|
||||
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
|
||||
|
||||
/**
|
||||
* 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。
|
||||
* 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。
|
||||
* ⚠️ 接口就绪后删除本常量及其兜底引用。
|
||||
*/
|
||||
export const DEMO_DASHBOARD_INPUT = {
|
||||
taskId: "DEMO-涉台舆情",
|
||||
taskName: "2026 第一季度涉台舆情态势研判",
|
||||
taskContent:
|
||||
"汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。",
|
||||
overview:
|
||||
"本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。",
|
||||
period: "2026-01-01 ~ 2026-03-31",
|
||||
kpis: {
|
||||
总信息量: 1284000,
|
||||
日均增速: "12%",
|
||||
正向占比: "58%",
|
||||
负向占比: "19%",
|
||||
识别风险点: 7,
|
||||
覆盖平台: 12,
|
||||
},
|
||||
channels: [
|
||||
{ platform: "微博", share: 34 },
|
||||
{ platform: "境外社媒(X/FB)", share: 27 },
|
||||
{ platform: "短视频", share: 18 },
|
||||
{ platform: "新闻门户", share: 13 },
|
||||
{ platform: "论坛/贴吧", share: 8 },
|
||||
],
|
||||
narratives: [
|
||||
{ theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" },
|
||||
{ theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" },
|
||||
{ theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" },
|
||||
{ theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" },
|
||||
],
|
||||
actors: [
|
||||
{ name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" },
|
||||
{ name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" },
|
||||
{ name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" },
|
||||
{ name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" },
|
||||
],
|
||||
risks: [
|
||||
{ label: "军事议题被恶意剪辑放大", level: "high" },
|
||||
{ label: "境外智库联动制造紧张框架", level: "high" },
|
||||
{ label: "个别大V情绪化带节奏", level: "mid" },
|
||||
{ label: "短视频谣言二次传播", level: "mid" },
|
||||
{ label: "节假日期间信息量骤增", level: "low" },
|
||||
],
|
||||
timeline: [
|
||||
{ phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" },
|
||||
{ phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" },
|
||||
{ phase: "行为体画像", desc: "关键账号立场与影响力建模" },
|
||||
{ phase: "风险研判", desc: "识别高危议题与传播链路" },
|
||||
{ phase: "处置建议", desc: "分级响应与正向引导预案" },
|
||||
],
|
||||
weeklyVolume: [
|
||||
{ week: "W1", value: 86 },
|
||||
{ week: "W2", value: 92 },
|
||||
{ week: "W3", value: 110 },
|
||||
{ week: "W4", value: 134 },
|
||||
{ week: "W5", value: 121 },
|
||||
{ week: "W6", value: 158 },
|
||||
],
|
||||
};
|
||||
|
||||
/** 「研判情报报告」接口完整地址:`{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport`。 */
|
||||
function sqReportUrl(): string {
|
||||
const value = readRuntimeEnv("VITE_AIPI_CONSUMER_URL");
|
||||
const base = typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
||||
return base ? `${base}/cop/selectSqReport` : "";
|
||||
}
|
||||
|
||||
/** 把单条 `contentJson` 反序列化为对象/数组/原值(已是对象则原样返回)。 */
|
||||
function parseContentJson(raw: unknown): unknown {
|
||||
if (typeof raw !== "string") return raw ?? null;
|
||||
const text = raw.trim();
|
||||
@@ -103,11 +21,11 @@ function parseContentJson(raw: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text; // 解析失败就把原始字符串透传给智能体,让它自行理解
|
||||
// 非 JSON 字符串仍透传给智能体,避免因单条异常让整份报告不可用。
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/** 去掉 categoryType 末尾的「 JSON」后缀,作为归类键(如「敌情关键事件 JSON」→「敌情关键事件」)。 */
|
||||
function categoryKey(categoryType: unknown, index: number): string {
|
||||
const raw = typeof categoryType === "string" ? categoryType.trim() : "";
|
||||
const cleaned = raw.replace(/\s*JSON\s*$/i, "").trim();
|
||||
@@ -115,157 +33,70 @@ function categoryKey(categoryType: unknown, index: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 selectSqReport 的整包响应**提取**成喂给智能体的干净结构化输入:
|
||||
* `{ taskId, source, categories: { [类目]: 反序列化后的内容 } }`
|
||||
* 返回 null 表示无可用内容(调用方据此回退/不开跑)。
|
||||
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
|
||||
* 返回 null 表示该任务尚未保存可用的报告内容。
|
||||
*/
|
||||
export function extractSqReport(body: unknown, taskId: string): unknown | null {
|
||||
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
if (!Array.isArray(data) || data.length === 0) return null;
|
||||
|
||||
const categories: Record<string, unknown> = {};
|
||||
data.forEach((item, index) => {
|
||||
const content = parseContentJson(item?.contentJson);
|
||||
if (content == null) return;
|
||||
let key = categoryKey(item?.categoryType, index);
|
||||
if (key in categories) key = `${key}(${index + 1})`; // 同名类目去重保留
|
||||
if (key in categories) key = `${key}(${index + 1})`;
|
||||
categories[key] = content;
|
||||
});
|
||||
if (Object.keys(categories).length === 0) return null;
|
||||
return { taskId, source: "cop/selectSqReport", categories };
|
||||
return { taskId, source: "api/task-reports", categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入 JSON 缓存**仅供开发(mock)使用**:`VITE_MOCK_SQ_REPORT==='true'` 时才读/写缓存,
|
||||
* 避免开发反复请求样例假数据。真实接口模式(false)下不读不写缓存,**始终拉取真实数据**。
|
||||
*/
|
||||
function inputCacheEnabled(): boolean {
|
||||
return readRuntimeFlag("VITE_MOCK_SQ_REPORT");
|
||||
}
|
||||
|
||||
/** 读取暂存的输入 JSON(仅 mock 开发模式生效,真实模式恒返回 null 以强制重新拉取)。 */
|
||||
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
|
||||
export function readDashboardInputJson(): unknown | null {
|
||||
if (!inputCacheEnabled()) return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 暂存输入 JSON(仅 mock 开发模式生效;隐私模式/quota 异常静默失败)。 */
|
||||
export function persistDashboardInputJson(data: unknown): void {
|
||||
if (!inputCacheEnabled()) return;
|
||||
try {
|
||||
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
/** 兼容旧调用方:报告数据只由后端接口维护。 */
|
||||
export function persistDashboardInputJson(_data: unknown): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */
|
||||
function placeholderFromCopTask(taskId: string): unknown | null {
|
||||
const detail = readCopTaskDetail();
|
||||
if (!detail) return taskId ? { taskId } : null;
|
||||
return {
|
||||
taskId,
|
||||
taskName: detail.taskName,
|
||||
taskContent: detail.taskContent,
|
||||
overview: detail.overview,
|
||||
taskDirection: detail.taskDirection,
|
||||
_note: "占位输入(真实业务 JSON 接口未就绪)",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。
|
||||
*
|
||||
* - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据——
|
||||
* **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」,
|
||||
* 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。
|
||||
* - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求;
|
||||
* 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。
|
||||
*/
|
||||
/** 查询并提取当前任务的报告详情;失败或空数据均返回 null。 */
|
||||
export async function fetchDashboardInputJson(
|
||||
taskId: string,
|
||||
opts?: { forceRefresh?: boolean },
|
||||
_opts?: { forceRefresh?: boolean },
|
||||
): Promise<unknown | null> {
|
||||
const mock = inputCacheEnabled(); // true=mock/开发;false=真实接口
|
||||
// forceRefresh(重新分析/修改时):跳过缓存,强制重新请求接口拿**最新数据**(数据可能已变化)。
|
||||
const cached = opts?.forceRefresh ? null : readDashboardInputJson(); // 真实模式恒 null(缓存仅开发用)
|
||||
if (cached) return cached;
|
||||
|
||||
const url = sqReportUrl();
|
||||
if (url && taskId) {
|
||||
try {
|
||||
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
|
||||
// await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
const extracted = extractSqReport(body, taskId);
|
||||
if (extracted) {
|
||||
persistDashboardInputJson(extracted); // 真实模式 no-op
|
||||
return extracted;
|
||||
if (!taskId) return null;
|
||||
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
|
||||
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
|
||||
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
|
||||
return extractSqReport(
|
||||
{
|
||||
...SQ_REPORT_MOCK_RESPONSE,
|
||||
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId })),
|
||||
},
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
|
||||
}
|
||||
}
|
||||
|
||||
// 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。
|
||||
if (!mock) return null;
|
||||
|
||||
// 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。
|
||||
const placeholder = placeholderFromCopTask(taskId);
|
||||
const detail = readCopTaskDetail();
|
||||
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
|
||||
if (resolved) persistDashboardInputJson(resolved);
|
||||
return resolved;
|
||||
const body = await getTaskReport(taskId);
|
||||
return extractSqReport(body, taskId);
|
||||
}
|
||||
|
||||
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
|
||||
|
||||
/**
|
||||
* 「3q(研判情报报告)是否完成」状态:
|
||||
* - ``complete`` = selectSqReport 数组里有有效内容;
|
||||
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
|
||||
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
|
||||
*/
|
||||
export type SqReportStatus = "complete" | "incomplete" | "unknown";
|
||||
|
||||
export interface SqReportCheckResult {
|
||||
status: SqReportStatus;
|
||||
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
|
||||
report?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
|
||||
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
|
||||
* - 有可用内容 → ``complete``(正常);
|
||||
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
|
||||
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
|
||||
*
|
||||
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
|
||||
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
|
||||
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
|
||||
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
|
||||
*/
|
||||
/** rwfx 进入前检查:任务已有可解析报告则 complete,否则 incomplete。 */
|
||||
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
|
||||
if (!taskId) return { status: "unknown" };
|
||||
const cfg = await fetchTaskDeeplinkConfig();
|
||||
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
|
||||
if (cfg.test) {
|
||||
const report = extractSqReport(cfg.mock.sqReport, taskId);
|
||||
return report ? { status: "complete", report } : { status: "incomplete" };
|
||||
}
|
||||
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
|
||||
const url = sqReportUrl();
|
||||
if (!url) return { status: "unknown" };
|
||||
try {
|
||||
const body = await requestCrudService.get(url, { taskId });
|
||||
const report = extractSqReport(body, taskId);
|
||||
const report = await fetchDashboardInputJson(taskId);
|
||||
return report ? { status: "complete", report } : { status: "incomplete" };
|
||||
} catch {
|
||||
return { status: "unknown" };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 按任务保存的研判情报报告详情。
|
||||
*
|
||||
* 后端 `/api/task-reports/by-task/{taskId}` 返回的外壳和原 Consumer
|
||||
* `selectSqReport` 完全一致:`{ state, msg, data }`。其中 `contentJson`
|
||||
* 保持为 JSON 字符串,由使用方按 `categoryType` 反序列化。
|
||||
*/
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type TaskReportTaskId = string | number;
|
||||
|
||||
export interface TaskReportRecord {
|
||||
id: string;
|
||||
taskId: TaskReportTaskId;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
sessionId: string;
|
||||
contentJson: string;
|
||||
categoryType: string;
|
||||
}
|
||||
|
||||
export interface TaskReportResponse {
|
||||
state: string;
|
||||
msg: string;
|
||||
data: TaskReportRecord[];
|
||||
}
|
||||
|
||||
/** 保存/修改时可省略由后端生成或维护的 id、创建/更新时间。 */
|
||||
export type TaskReportRecordDraft = Omit<
|
||||
TaskReportRecord,
|
||||
"id" | "taskId" | "createTime" | "updateTime"
|
||||
> &
|
||||
Partial<Pick<TaskReportRecord, "id" | "taskId" | "createTime" | "updateTime">>;
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/task-reports/by-task`;
|
||||
|
||||
function endpoint(taskId: TaskReportTaskId) {
|
||||
return `${BASE()}/${encodeURIComponent(String(taskId))}`;
|
||||
}
|
||||
|
||||
async function readResponse(res: Response, context: string): Promise<TaskReportResponse> {
|
||||
const body = (await res.json().catch(() => null)) as TaskReportResponse | { detail?: unknown } | null;
|
||||
if (!res.ok) {
|
||||
const detail = body && typeof body === "object" && "detail" in body ? String(body.detail) : "";
|
||||
throw new Error(detail || `${context} failed (${res.status})`);
|
||||
}
|
||||
if (!body || !Array.isArray((body as TaskReportResponse).data)) {
|
||||
throw new Error(`${context} returned an invalid report payload`);
|
||||
}
|
||||
return body as TaskReportResponse;
|
||||
}
|
||||
|
||||
/** 查询任务详情;尚未保存时后端返回 `{ state: "200", data: [] }`。 */
|
||||
export async function getTaskReport(taskId: TaskReportTaskId): Promise<TaskReportResponse> {
|
||||
const res = await apiFetch(endpoint(taskId), { method: "GET" });
|
||||
return readResponse(res, "get task report");
|
||||
}
|
||||
|
||||
/** 首次保存一个任务的完整 `data` 数组。任务已有详情时会返回 409。 */
|
||||
export async function saveTaskReport(
|
||||
taskId: TaskReportTaskId,
|
||||
data: TaskReportRecordDraft[],
|
||||
): Promise<TaskReportResponse> {
|
||||
const res = await apiFetch(endpoint(taskId), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
return readResponse(res, "save task report");
|
||||
}
|
||||
|
||||
/** 修改一个任务的完整 `data` 数组。保留同 id 项的原始 createTime。 */
|
||||
export async function updateTaskReport(
|
||||
taskId: TaskReportTaskId,
|
||||
data: TaskReportRecordDraft[],
|
||||
): Promise<TaskReportResponse> {
|
||||
const res = await apiFetch(endpoint(taskId), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
return readResponse(res, "update task report");
|
||||
}
|
||||
+48
-44
@@ -1,63 +1,67 @@
|
||||
import sqReportMock from './sq-report-mock.json';
|
||||
import sqReportMock from "./sq-report-mock.json";
|
||||
|
||||
/**
|
||||
* 「研判情报报告」接口 `/cop/selectSqReport` 的假数据 —— 给「大屏绘制智能体」独立页面
|
||||
* (深链 `?taskId=` 模式)在真实接口未就绪时本地预览。
|
||||
* 研判情报报告的契约样例。
|
||||
*
|
||||
* 真实接口(GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`)返回结构:
|
||||
* `{ state, msg, data: [{ id, taskId, createTime, updateTime, sessionId, contentJson, categoryType }] }`
|
||||
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
|
||||
* 假数据 `sq-report-mock.json` 即后端给出的样例响应(taskId=3332),原样保留 contentJson 字符串。
|
||||
*
|
||||
* 启停由环境变量 `VITE_MOCK_SQ_REPORT` 控制(见 `utils/request.ts` 请求拦截器):
|
||||
* - `VITE_MOCK_SQ_REPORT=true`(默认,.env.development) → 走本地假数据;
|
||||
* - `VITE_MOCK_SQ_REPORT=false`(.env.production / .env.network / .env.sit) → 走真实接口。
|
||||
* 与 `VITE_MOCK_COP_TASK_DETAIL`(cop-task-detail)独立,机制完全一致。
|
||||
* ⚠️ 真实接口联调通过后删除本 mock 及其引用。
|
||||
* `sq-report-mock.json` 是外部系统提供的真实字段格式,保留在仓库中供接口联调、
|
||||
* 手工保存测试和解析器测试使用。运行时不再将它注入请求;大屏与 rwfx 检查统一通过
|
||||
* `/api/task-reports/by-task/{taskId}` 读取数据库保存的数据。
|
||||
*/
|
||||
|
||||
/** 命中本 mock 的接口路径。 */
|
||||
export const SQ_REPORT_ROUTE = '/cop/selectSqReport';
|
||||
export interface SqReportMockItem {
|
||||
id: string;
|
||||
taskId: string | number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
sessionId: string;
|
||||
contentJson: string;
|
||||
categoryType: string;
|
||||
}
|
||||
|
||||
/** 判断某请求 url 是否是 selectSqReport。 */
|
||||
export const SQ_REPORT_MOCK_RESPONSE = sqReportMock as {
|
||||
state: string;
|
||||
msg: string;
|
||||
data: SqReportMockItem[];
|
||||
};
|
||||
|
||||
/** 大屏 mock 命中的真实接口路径。 */
|
||||
export const SQ_REPORT_ROUTE = "/cop/selectSqReport";
|
||||
|
||||
/** 供 axios 请求拦截器判断是否需要注入大屏样例数据。 */
|
||||
export function matchSqReportMock(url?: string): boolean {
|
||||
return !!url && url.includes(SQ_REPORT_ROUTE);
|
||||
return Boolean(url?.includes(SQ_REPORT_ROUTE));
|
||||
}
|
||||
|
||||
/** 从 axios config 取 taskId(优先 params,其次 url query)。 */
|
||||
function readTaskIdFromConfig(config: { url?: string; params?: Record<string, unknown> }): string {
|
||||
function readTaskId(config: { url?: string; params?: Record<string, unknown> }): string {
|
||||
const fromParams = config.params?.taskId;
|
||||
if (fromParams != null && fromParams !== '') return String(fromParams);
|
||||
if (config.url) {
|
||||
const m = config.url.match(/[?&]taskId=([^&]+)/);
|
||||
if (m) return decodeURIComponent(m[1]);
|
||||
}
|
||||
return '';
|
||||
if (fromParams != null && fromParams !== "") return String(fromParams);
|
||||
const matched = config.url?.match(/[?&]taskId=([^&]+)/);
|
||||
return matched ? decodeURIComponent(matched[1]) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 selectSqReport 的 body:返回后端给的样例响应,并把 data 里每条的 taskId 改成
|
||||
* 当前请求的 taskId(让任意 taskId 进来都能看到这份演示报告)。
|
||||
* 以真实 selectSqReport 的响应结构返回样例数据;请求 taskId 会写回每条记录,
|
||||
* 让任意深链(例如 demo1)都能加载同一套可解析的大屏数据。
|
||||
*/
|
||||
function buildSqReportBody(taskId: string) {
|
||||
const tid = taskId || String((sqReportMock as { data?: Array<{ taskId?: unknown }> }).data?.[0]?.taskId ?? '');
|
||||
const src = sqReportMock as { state: string; msg: string; data: Array<Record<string, unknown>> };
|
||||
return {
|
||||
state: src.state,
|
||||
msg: src.msg,
|
||||
data: (src.data ?? []).map((item) => ({ ...item, taskId: tid })),
|
||||
};
|
||||
}
|
||||
|
||||
/** 临时假数据 axios adapter:命中 selectSqReport 时直接返回 mock body,不发真实请求。 */
|
||||
export function sqReportMockAdapter(config: {
|
||||
url?: string;
|
||||
params?: Record<string, unknown>;
|
||||
}): Promise<{ data: unknown; status: number; statusText: string; headers: Record<string, unknown>; config: unknown }> {
|
||||
const body = buildSqReportBody(readTaskIdFromConfig(config));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ data: body, status: 200, statusText: 'OK', headers: {}, config });
|
||||
}, 220);
|
||||
}): Promise<{
|
||||
data: typeof SQ_REPORT_MOCK_RESPONSE;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, unknown>;
|
||||
config: unknown;
|
||||
}> {
|
||||
const taskId = readTaskId(config);
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
...SQ_REPORT_MOCK_RESPONSE,
|
||||
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId: taskId || item.taskId })),
|
||||
},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
});
|
||||
}
|
||||
@@ -22,9 +22,7 @@ const USE_REAL_DATA = readRuntimeFlag('VITE_USE_REAL_DATA')
|
||||
// 与 USE_REAL_DATA 独立。改 env 后需重启 dev server 生效。
|
||||
const MOCK_COP_TASK_DETAIL = readRuntimeFlag('VITE_MOCK_COP_TASK_DETAIL')
|
||||
|
||||
// 研判情报报告(selectSqReport,大屏绘制智能体深链用)数据源开关:
|
||||
// 默认真实数据;仅当 VITE_MOCK_SQ_REPORT=true 时走本地假数据(真实接口未就绪时调试用)。
|
||||
// 与上面两个开关独立。改 env 后需重启 dev server 生效。
|
||||
// 大屏绘制(selectSqReport)数据源开关:仅当开启时拦截为本地样例数据。
|
||||
const MOCK_SQ_REPORT = readRuntimeFlag('VITE_MOCK_SQ_REPORT')
|
||||
|
||||
// axios.interceptors
|
||||
@@ -67,7 +65,7 @@ service.interceptors.request.use(
|
||||
if (MOCK_COP_TASK_DETAIL && matchCopTaskDetailMock(config.url)) {
|
||||
config.adapter = copTaskDetailMockAdapter as any;
|
||||
}
|
||||
// 研判情报报告(大屏绘制智能体深链):默认真实接口;仅 VITE_MOCK_SQ_REPORT=true 时走本地假数据。
|
||||
// 大屏绘制:mock 模式下返回与真实 selectSqReport 完全相同的响应结构。
|
||||
if (MOCK_SQ_REPORT && matchSqReportMock(config.url)) {
|
||||
config.adapter = sqReportMockAdapter as any;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ SERPER_API_KEY=your-serper-api-key
|
||||
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
|
||||
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
|
||||
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://localhost:5183,http://127.0.0.1:5183,http://localhost:4200,http://47.88.25.99:7010
|
||||
GATEWAY_CORS_ALLOW_ALL=0
|
||||
GATEWAY_ENABLE_DOCS=true
|
||||
GATEWAY_WORKERS=2
|
||||
|
||||
@@ -258,12 +258,19 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
|
||||
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
|
||||
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
|
||||
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 |
|
||||
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
|
||||
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
|
||||
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
|
||||
|
||||
### TaskCOP Situation-Overview Compatibility
|
||||
|
||||
The standalone legacy Vue page calls the Gateway URL in its runtime `b1ConsumerUrl` configuration directly (no Vite `/api` reverse proxy). The Gateway CORS middleware merges the local TaskCOP origins `http://localhost:8080` and `http://127.0.0.1:8080` even when `GATEWAY_CORS_ORIGINS` was set for another frontend; `scripts/env.sh` includes the same pair in its default startup value. Deployments must set `GATEWAY_CORS_ORIGINS` to any additional exact frontend origins that are allowed to call the Gateway.
|
||||
|
||||
`app/gateway/routers/taskcop_tasks.py` is a temporary compatibility layer for the separately deployed legacy Vue page at `/situation-overview/action/sentiment` and its task import page at `/situation-overview/action/task/import`. It preserves the old Consumer paths and response envelopes: `POST /cop/saveSuperiorTask` creates a row with a sequential four-digit string id (`0001`, `0002`, ...) and returns `{state: 201, msg, data}`; `POST /api/taskcop/import/tasks` accepts a JSON array or `tasks`/`records`/legacy list envelope, uses each supplied `id` or `taskId` as the primary key, and completely replaces duplicate IDs; `DELETE /cop/tasks/{task_id}` removes that task and its shared report document; `GET /taskAnalyseSearch/cop-task-three-list` (all), `GET /taskAnalyseSearch/cop-my-tasks` (creator-scoped), and `GET /taskAnalyseSearch/cop-task-list` return `{state: 200, data: {total, records}}`; `GET /taskAnalyseSearch/cop-task-detail?taskId=` returns one task. Existing UUID-style task rows are retained, and do not affect the new numeric sequence. An empty TaskCOP store remains empty—there are no built-in task or report seed records. It supports the page's existing `content`, `taskStatus`, `taskDirection`, `startTime`, `endTime`, `pageNum`, and `pageSize` query fields. Tasks are stored in `taskcop_tasks` through `deerflow.persistence.taskcop_tasks`, registered by `persistence.models`, and initialized as `app.state.taskcop_task_store` in `deps.py`. A non-empty save/update/import through `/api/task-reports/*` calls `mark_analysis_completed(task_id)`: it changes only pre-completion task states to `25`, so the list renders and filters the task as “已完成” without downgrading later workflow states. `trendSucc` is derived from the same status for the legacy sentiment list. Zero-padded task ids stay strings in the report API, so `0001` is never converted to `1`. The Vue router calls DeerFlow's `/api/v1/auth/login/username` directly with an incoming `username`/`userId`/`yUserId` parameter; it does not depend on the retired Consumer login service, and sends the returned DeerFlow JWT to every TaskCOP call. The TaskCOP routes are no longer public or CSRF-exempt. The Gateway fallback CORS allow-list includes TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`; deployments must use explicit `GATEWAY_CORS_ORIGINS`. Tests: `tests/test_taskcop_tasks.py` and `tests/test_taskcop_cors.py`.
|
||||
|
||||
### AI Writing Intent Router (对话驱动写作台意图路由)
|
||||
|
||||
Backs the frontend「AI 写作·对话版」page's unified bottom composer. `POST /api/ai-writing/sessions/{session_id}/intent` (owner-checked; in `routers/ai_writing.py`) reads the session's graph checkpoint via the shared `app.state.checkpointer` — the **pending interrupt is the authoritative pause point** (the frontend's claimed `pause_point` is only a fallback when the checkpoint can't be read) — then routes the user's free text through `deerflow/agents/ai_writing/intent_router.py::parse_user_intent`: an LLM call (session's `model_name` + `fallback_chain` model fault-tolerance) that returns `{intent, pause_point, action, payload, need_research, research_query, answer, confidence, clarify}`. Safety boundaries in `normalize_intent_result`: **per-pause action whitelist strictly aligned to graph.py's real resume actions** (`ALLOWED_ACTIONS`), no pause → no action, high-cost actions (`finalize` / `force_finalize` / query-less覆盖式 `re_search`) require confidence ≥ 0.85 else downgrade to `clarify`, payload key-whitelist cleaning (material-id filtering, review-issue index bounds, section-decision back-fill with the majority decision). QA intents return `answer` directly from the context snapshot (`build_context_snapshot` — stage-tailored: materials with ids / outline / draft body capped / review issues with indices). Model failures never 5xx — they degrade to clarify. Prompts in `agents/ai_writing/prompts/intent_router_prompts.py`; tests `tests/test_ai_writing_intent.py`.
|
||||
@@ -837,6 +844,10 @@ Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The sha
|
||||
|
||||
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
||||
|
||||
### WeKnora Retrieval Skill
|
||||
|
||||
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.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.
|
||||
|
||||
@@ -78,6 +78,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **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
|
||||
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
|
||||
- **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)
|
||||
|
||||
@@ -131,6 +132,14 @@ 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 |
|
||||
| `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 |
|
||||
| `DELETE /cop/tasks/{task_id}` | TaskCOP compatibility API (authenticated): delete one task and its task-scoped situation-report document |
|
||||
| `GET /taskAnalyseSearch/cop-task-three-list` | TaskCOP compatibility API (authenticated): paginated task list for the situation-overview sentiment page (`content`, `taskStatus`, `taskDirection`, `startTime`, `endTime`) |
|
||||
| `GET/POST/PUT /api/task-reports/by-task/{task_id}` | Shared task-scoped situation-report detail. Query, first save, and full update all use the legacy-compatible `{state, msg, data:[{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}]}` shape; `contentJson` is preserved as a JSON string. Saving a non-empty report automatically moves the matching TaskCOP task to status `25` (the legacy list's “已完成” state). |
|
||||
| `POST /api/task-reports/import/by-task/{task_id}` | TaskCOP detail import (authenticated): accept a `sq-report-mock.json`-compatible `data`/`records` array and atomically replace the selected task's full report. The path task id overrides task ids inside the file. |
|
||||
|
||||
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges the local TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. `scripts/env.sh` also includes them in its startup default. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
|
||||
|
||||
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ from app.gateway.routers import (
|
||||
system_settings,
|
||||
tags,
|
||||
task_buttons,
|
||||
taskcop_tasks,
|
||||
task_reports,
|
||||
thread_runs,
|
||||
tool_metrics,
|
||||
thread_shares,
|
||||
@@ -85,6 +87,7 @@ from app.gateway.routers import (
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
weknora_sources,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
@@ -1329,6 +1332,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
|
||||
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
|
||||
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
|
||||
# The standalone TaskCOP Vue UI is served on a separate Vite dev server and
|
||||
# calls the Gateway directly via b1ConsumerUrl. Keep these local origins in
|
||||
# the Gateway policy even when another frontend has set CORS origins.
|
||||
taskcop_vite_origins = ["http://localhost:8080", "http://127.0.0.1:8080"]
|
||||
if cors_origins:
|
||||
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
|
||||
|
||||
# Headers the LangGraph SDK / browser clients need to read from cross-origin
|
||||
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
|
||||
@@ -1341,6 +1350,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
"X-Thread-Id",
|
||||
]
|
||||
|
||||
# Public pages (e.g. http://47.x.x.x:7010) calling this Gateway on localhost
|
||||
# trigger Chrome/Edge Private Network Access preflights. Without
|
||||
# allow_private_network=True the browser reports a generic CORS failure even
|
||||
# when Access-Control-Allow-Origin is correct.
|
||||
cors_kwargs = {
|
||||
"allow_credentials": True,
|
||||
"allow_methods": ["*"],
|
||||
"allow_headers": ["*"],
|
||||
"expose_headers": cors_expose_headers,
|
||||
"allow_private_network": True,
|
||||
}
|
||||
|
||||
if _cors_allow_any_origin():
|
||||
logger.warning(
|
||||
"Permissive CORS enabled ? any Origin is echoed back (GATEWAY_CORS_ALLOW_ALL or "
|
||||
@@ -1351,10 +1372,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
allow_origins=[],
|
||||
# fullmatch() on the Origin header; browsers send scheme://host[:port]
|
||||
allow_origin_regex=r".+",
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=cors_expose_headers,
|
||||
**cors_kwargs,
|
||||
)
|
||||
elif cors_origins:
|
||||
if "*" in cors_origins:
|
||||
@@ -1367,10 +1385,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=cors_expose_headers,
|
||||
**cors_kwargs,
|
||||
)
|
||||
else:
|
||||
# Safe-by-default fallback for local frontend development. Without any CORS
|
||||
@@ -1381,6 +1396,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
*taskcop_vite_origins,
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
]
|
||||
@@ -1392,10 +1408,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=dev_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=cors_expose_headers,
|
||||
**cors_kwargs,
|
||||
)
|
||||
|
||||
# Include routers
|
||||
@@ -1405,6 +1418,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
|
||||
# Authenticated source-context proxy for the WeKnora retrieval skill.
|
||||
app.include_router(weknora_sources.router)
|
||||
|
||||
# Memory API is mounted at /api/memory
|
||||
app.include_router(memory.router)
|
||||
|
||||
@@ -1467,6 +1483,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
|
||||
app.include_router(task_buttons.router)
|
||||
|
||||
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
|
||||
app.include_router(taskcop_tasks.router)
|
||||
|
||||
# Task-scoped situation-report details used by dashboard drawing / task gates.
|
||||
app.include_router(task_reports.router)
|
||||
|
||||
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
|
||||
app.include_router(parallel_agents.router)
|
||||
app.include_router(parallel_agent_tasks.router)
|
||||
|
||||
@@ -283,6 +283,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.task_button_store = make_task_button_store(sf)
|
||||
|
||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||
|
||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||
|
||||
from deerflow.persistence.task_reports import make_task_report_store
|
||||
|
||||
app.state.task_report_store = make_task_report_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Task-scoped situation-report detail API.
|
||||
|
||||
The report payload intentionally mirrors the existing ``selectSqReport``
|
||||
Consumer contract:
|
||||
``{state, msg, data: [{id, taskId, createTime, updateTime, sessionId,
|
||||
contentJson, categoryType}]}``. ``contentJson`` stays a JSON string so the
|
||||
dashboard parser can continue to deserialize it category by category.
|
||||
|
||||
Routes (all authenticated by the Gateway middleware):
|
||||
GET /api/task-reports/by-task/{task_id}
|
||||
POST /api/task-reports/by-task/{task_id}
|
||||
PUT /api/task-reports/by-task/{task_id}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictStr
|
||||
|
||||
from app.gateway.deps import get_current_user
|
||||
from deerflow.persistence.task_reports import TaskReportStore
|
||||
|
||||
router = APIRouter(prefix="/api/task-reports", tags=["task-reports"])
|
||||
|
||||
|
||||
class TaskReportRecordInput(BaseModel):
|
||||
"""One ``data`` entry in the legacy-compatible report envelope."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: StrictStr | None = Field(default=None, max_length=128)
|
||||
task_id: str | int | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("taskId", "task_id"),
|
||||
)
|
||||
create_time: StrictStr | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("createTime", "create_time"),
|
||||
)
|
||||
update_time: StrictStr | None = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("updateTime", "update_time"),
|
||||
)
|
||||
session_id: StrictStr = Field(
|
||||
default="",
|
||||
max_length=255,
|
||||
validation_alias=AliasChoices("sessionId", "session_id"),
|
||||
)
|
||||
content_json: StrictStr = Field(
|
||||
...,
|
||||
validation_alias=AliasChoices("contentJson", "content_json"),
|
||||
)
|
||||
category_type: StrictStr = Field(
|
||||
...,
|
||||
max_length=255,
|
||||
validation_alias=AliasChoices("categoryType", "category_type"),
|
||||
)
|
||||
|
||||
|
||||
class TaskReportWriteRequest(BaseModel):
|
||||
data: list[TaskReportRecordInput] = Field(min_length=1)
|
||||
|
||||
|
||||
class TaskReportRecordResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: str
|
||||
task_id: int | str = Field(serialization_alias="taskId")
|
||||
create_time: str = Field(serialization_alias="createTime")
|
||||
update_time: str = Field(serialization_alias="updateTime")
|
||||
session_id: str = Field(serialization_alias="sessionId")
|
||||
content_json: str = Field(serialization_alias="contentJson")
|
||||
category_type: str = Field(serialization_alias="categoryType")
|
||||
|
||||
|
||||
class TaskReportResponse(BaseModel):
|
||||
state: str = "200"
|
||||
msg: str = "操作成功!"
|
||||
data: list[TaskReportRecordResponse]
|
||||
|
||||
|
||||
def _get_store(request: Request) -> TaskReportStore:
|
||||
store = getattr(request.app.state, "task_report_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Task report store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _mark_task_analysis_completed(request: Request, task_id: str) -> None:
|
||||
"""Reflect a non-empty saved report in the TaskCOP task-list status."""
|
||||
task_store = getattr(request.app.state, "taskcop_task_store", None)
|
||||
if task_store is not None:
|
||||
await task_store.mark_analysis_completed(task_id)
|
||||
|
||||
|
||||
def _public_task_id(task_id: str) -> int | str:
|
||||
"""Keep numeric task ids numeric, matching the supplied mock payload."""
|
||||
return int(task_id) if task_id.isdecimal() and task_id == str(int(task_id)) else task_id
|
||||
|
||||
|
||||
def _to_response(records: list[dict[str, Any]]) -> TaskReportResponse:
|
||||
return TaskReportResponse(
|
||||
data=[
|
||||
TaskReportRecordResponse(
|
||||
id=str(record["id"]),
|
||||
task_id=record["taskId"],
|
||||
create_time=str(record["createTime"]),
|
||||
update_time=str(record["updateTime"]),
|
||||
session_id=str(record["sessionId"]),
|
||||
content_json=str(record["contentJson"]),
|
||||
category_type=str(record["categoryType"]),
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _normalize_records(
|
||||
task_id: str,
|
||||
data: list[TaskReportRecordInput],
|
||||
previous: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the exact public record shape and preserve original create times."""
|
||||
existing_by_id = {str(item.get("id")): item for item in previous or [] if item.get("id")}
|
||||
now = datetime.now(UTC).isoformat()
|
||||
normalized: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for item in data:
|
||||
if item.task_id is not None and str(item.task_id) != task_id:
|
||||
raise HTTPException(status_code=422, detail="data.taskId must match the path task id")
|
||||
record_id = item.id or uuid4().hex
|
||||
if record_id in seen_ids:
|
||||
raise HTTPException(status_code=422, detail="data contains duplicate id values")
|
||||
seen_ids.add(record_id)
|
||||
before = existing_by_id.get(record_id, {})
|
||||
normalized.append(
|
||||
{
|
||||
"id": record_id,
|
||||
"taskId": _public_task_id(task_id),
|
||||
# Importing the supplied mock retains its createTime. On later
|
||||
# edits, matching ids retain their original createTime.
|
||||
"createTime": before.get("createTime") or item.create_time or now,
|
||||
"updateTime": now,
|
||||
"sessionId": item.session_id,
|
||||
"contentJson": item.content_json,
|
||||
"categoryType": item.category_type,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _import_records(payload: Any, task_id: str) -> list[TaskReportRecordInput]:
|
||||
"""Read the supplied mock envelope and bind every record to ``task_id``.
|
||||
|
||||
Detail files are frequently exported from another task. The import page
|
||||
deliberately makes the selected task authoritative, so stale taskId values
|
||||
inside the file cannot write or reject the intended target.
|
||||
"""
|
||||
if isinstance(payload, list):
|
||||
raw_records = payload
|
||||
elif isinstance(payload, dict):
|
||||
raw_records = payload.get("data", payload.get("records"))
|
||||
else:
|
||||
raw_records = None
|
||||
|
||||
if not isinstance(raw_records, list) or not raw_records:
|
||||
raise HTTPException(status_code=422, detail="详情导入文件必须包含非空的 data 或 records 数组")
|
||||
|
||||
records: list[TaskReportRecordInput] = []
|
||||
for index, raw in enumerate(raw_records, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
raise HTTPException(status_code=422, detail=f"第 {index} 条详情数据不是对象")
|
||||
value = dict(raw)
|
||||
content_key = "contentJson" if "contentJson" in value else "content_json"
|
||||
if content_key in value and not isinstance(value[content_key], str):
|
||||
value[content_key] = json.dumps(value[content_key], ensure_ascii=False, separators=(",", ":"))
|
||||
# Prefer the public key so AliasChoices resolves this value consistently.
|
||||
value.pop("task_id", None)
|
||||
value["taskId"] = task_id
|
||||
try:
|
||||
records.append(TaskReportRecordInput.model_validate(value))
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=422, detail=f"第 {index} 条详情数据格式不正确:{error}") from error
|
||||
return records
|
||||
|
||||
|
||||
@router.get("/by-task/{task_id}", response_model=TaskReportResponse)
|
||||
async def get_task_report(request: Request, task_id: str) -> TaskReportResponse:
|
||||
"""Read a task report. Missing reports use the same envelope with ``data: []``."""
|
||||
row = await _get_store(request).get_by_task(task_id)
|
||||
return _to_response(row.get("records", []) if row is not None else [])
|
||||
|
||||
|
||||
@router.post("/by-task/{task_id}", response_model=TaskReportResponse)
|
||||
async def create_task_report(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
body: TaskReportWriteRequest,
|
||||
) -> TaskReportResponse:
|
||||
"""Save a task's first report document; reject duplicate initial saves."""
|
||||
records = _normalize_records(task_id, body.data)
|
||||
row = await _get_store(request).create_by_task(
|
||||
task_id,
|
||||
records,
|
||||
updated_by=await get_current_user(request),
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=409, detail="Task report already exists; use PUT to modify it")
|
||||
await _mark_task_analysis_completed(request, task_id)
|
||||
return _to_response(row["records"])
|
||||
|
||||
|
||||
@router.put("/by-task/{task_id}", response_model=TaskReportResponse)
|
||||
async def update_task_report(
|
||||
request: Request,
|
||||
task_id: str,
|
||||
body: TaskReportWriteRequest,
|
||||
) -> TaskReportResponse:
|
||||
"""Modify a report by atomically replacing its complete ``data`` array."""
|
||||
store = _get_store(request)
|
||||
current = await store.get_by_task(task_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Task report not found; use POST to save it first")
|
||||
records = _normalize_records(task_id, body.data, current.get("records", []))
|
||||
row = await store.update_by_task(
|
||||
task_id,
|
||||
records,
|
||||
updated_by=await get_current_user(request),
|
||||
)
|
||||
if row is None: # defensive: another process could remove it between read and write
|
||||
raise HTTPException(status_code=404, detail="Task report not found")
|
||||
await _mark_task_analysis_completed(request, task_id)
|
||||
return _to_response(row["records"])
|
||||
|
||||
|
||||
@router.post("/import/by-task/{task_id}", response_model=TaskReportResponse)
|
||||
async def import_task_report(
|
||||
request: Request, task_id: str, payload: Any = Body(...)
|
||||
) -> TaskReportResponse:
|
||||
"""Import and replace a selected task's complete report document."""
|
||||
task_id = task_id.strip()
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=422, detail="task id is required")
|
||||
|
||||
task_store = getattr(request.app.state, "taskcop_task_store", None)
|
||||
if task_store is not None and await task_store.get_task(task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在,请先导入或创建任务")
|
||||
|
||||
store = _get_store(request)
|
||||
current = await store.get_by_task(task_id)
|
||||
records = _normalize_records(task_id, _import_records(payload, task_id), current.get("records", []) if current else None)
|
||||
if current is None:
|
||||
row = await store.create_by_task(task_id, records, updated_by=await get_current_user(request))
|
||||
else:
|
||||
row = await store.update_by_task(task_id, records, updated_by=await get_current_user(request))
|
||||
|
||||
if row is None: # defensive: a concurrent delete/import may have won the race
|
||||
raise HTTPException(status_code=409, detail="详情导入时发生并发冲突,请重试")
|
||||
await _mark_task_analysis_completed(request, task_id)
|
||||
return _to_response(row["records"])
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Temporary compatibility API for the TaskCOP situation-overview frontend.
|
||||
|
||||
The Vue page at ``/situation-overview/action/sentiment`` currently talks to a
|
||||
legacy Consumer service. These routes retain that service's paths and response
|
||||
envelopes while persisting data in DeerFlow's own database:
|
||||
|
||||
* ``POST /cop/saveSuperiorTask``
|
||||
* ``GET /taskAnalyseSearch/cop-task-three-list``
|
||||
* ``GET /taskAnalyseSearch/cop-my-tasks``
|
||||
* ``GET /taskAnalyseSearch/cop-task-detail``
|
||||
|
||||
The separately deployed legacy UI signs in through DeerFlow before calling
|
||||
these routes. The Gateway authentication middleware therefore supplies the
|
||||
creator identity, which keeps the "my tasks" endpoint correctly scoped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, field_validator
|
||||
|
||||
from deerflow.runtime.user_context import DEFAULT_USER_ID, get_effective_user_id
|
||||
|
||||
router = APIRouter(tags=["taskcop-compat"])
|
||||
|
||||
|
||||
class SaveSuperiorTaskRequest(BaseModel):
|
||||
"""Payload posted by the legacy ``AddTaskDialog`` component."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
issuing_dept: str = Field(default="", validation_alias=AliasChoices("issuingDept", "issuing_dept"))
|
||||
overview: str = Field(min_length=1, max_length=50_000)
|
||||
task_direction: str = Field(
|
||||
default="W方向", validation_alias=AliasChoices("taskDirection", "task_direction"), max_length=64
|
||||
)
|
||||
task_end_date: datetime | None = Field(
|
||||
default=None, validation_alias=AliasChoices("taskEndDate", "task_end_date")
|
||||
)
|
||||
task_level: str = Field(
|
||||
default="1", validation_alias=AliasChoices("taskLevel", "task_level"), max_length=32
|
||||
)
|
||||
task_name: str = Field(
|
||||
min_length=1, max_length=512, validation_alias=AliasChoices("taskName", "task_name")
|
||||
)
|
||||
task_no: str = Field(default="", validation_alias=AliasChoices("taskNo", "task_no"), max_length=255)
|
||||
task_start_date: datetime | None = Field(
|
||||
default=None, validation_alias=AliasChoices("taskStartDate", "task_start_date")
|
||||
)
|
||||
task_status: int = Field(default=0, validation_alias=AliasChoices("taskStatus", "task_status"))
|
||||
task_superior_flag: int = Field(
|
||||
default=0, validation_alias=AliasChoices("taskSuperiorFlag", "task_superior_flag")
|
||||
)
|
||||
task_type: int = Field(default=1, validation_alias=AliasChoices("taskType", "task_type"))
|
||||
parent_task_id: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("pTaskId", "parent_task_id"), max_length=64
|
||||
)
|
||||
|
||||
|
||||
class TaskImportRecord(BaseModel):
|
||||
"""One task accepted by the JSON import page.
|
||||
|
||||
Both the legacy ``taskName/taskContent`` keys and the internal snake_case
|
||||
keys are accepted, so exported task-list files can be uploaded unchanged.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: str = Field(..., validation_alias=AliasChoices("id", "taskId"), max_length=64)
|
||||
task_name: str = Field(
|
||||
..., validation_alias=AliasChoices("taskName", "task_name"), min_length=1, max_length=512
|
||||
)
|
||||
overview: str = Field(
|
||||
default="", validation_alias=AliasChoices("taskContent", "overview"), max_length=50_000
|
||||
)
|
||||
issuing_dept: str = Field(default="", validation_alias=AliasChoices("issuingDept", "issuing_dept"))
|
||||
task_direction: str = Field(
|
||||
default="W方向", validation_alias=AliasChoices("taskDirection", "task_direction"), max_length=64
|
||||
)
|
||||
task_end_date: datetime | None = Field(
|
||||
default=None, validation_alias=AliasChoices("taskEndDate", "task_end_date")
|
||||
)
|
||||
task_level: str = Field(
|
||||
default="1", validation_alias=AliasChoices("taskLevel", "task_level"), max_length=32
|
||||
)
|
||||
task_no: str = Field(default="", validation_alias=AliasChoices("taskNo", "task_no"), max_length=255)
|
||||
task_start_date: datetime | None = Field(
|
||||
default=None, validation_alias=AliasChoices("taskStartDate", "task_start_date")
|
||||
)
|
||||
task_status: int = Field(default=0, validation_alias=AliasChoices("taskStatus", "task_status"))
|
||||
task_superior_flag: int = Field(
|
||||
default=0, validation_alias=AliasChoices("taskSuperiorFlag", "task_superior_flag")
|
||||
)
|
||||
task_type: int = Field(default=1, validation_alias=AliasChoices("taskType", "task_type"))
|
||||
parent_task_id: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("pTaskId", "parent_task_id"), max_length=64
|
||||
)
|
||||
|
||||
@field_validator("id", "task_name")
|
||||
@classmethod
|
||||
def strip_required_text(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
def _store(request: Request):
|
||||
store = getattr(request.app.state, "taskcop_task_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="TaskCOP task store not available")
|
||||
return store
|
||||
|
||||
|
||||
def _request_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is not None:
|
||||
return str(user.id)
|
||||
try:
|
||||
return get_effective_user_id()
|
||||
except RuntimeError:
|
||||
return DEFAULT_USER_ID
|
||||
|
||||
|
||||
def _parse_statuses(task_status: str | None) -> set[int] | None:
|
||||
if not task_status or not task_status.strip():
|
||||
return None
|
||||
try:
|
||||
return {int(value.strip()) for value in task_status.split(",") if value.strip()}
|
||||
except ValueError:
|
||||
# The legacy service treats malformed filters as no filter.
|
||||
return None
|
||||
|
||||
|
||||
def _bounded_page(page_num: int, page_size: int) -> tuple[int, int]:
|
||||
return max(page_num, 1), min(max(page_size, 1), 100)
|
||||
|
||||
|
||||
async def _list_tasks(
|
||||
request: Request,
|
||||
*,
|
||||
mine: bool,
|
||||
content: str,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
task_status: str | None,
|
||||
task_direction: str | None,
|
||||
start_time: datetime | None,
|
||||
end_time: datetime | None,
|
||||
) -> dict:
|
||||
page_num, page_size = _bounded_page(page_num, page_size)
|
||||
total, records = await _store(request).list_tasks(
|
||||
created_by=_request_user_id(request) if mine else None,
|
||||
keyword=content,
|
||||
task_direction=task_direction,
|
||||
task_statuses=_parse_statuses(task_status),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
return {"state": 200, "msg": "查询成功", "data": {"total": total, "records": records}}
|
||||
|
||||
|
||||
TaskListQuery = Annotated[str, Query(max_length=512)]
|
||||
|
||||
|
||||
@router.post("/cop/saveSuperiorTask", status_code=201)
|
||||
async def save_superior_task(request: Request, body: SaveSuperiorTaskRequest) -> dict:
|
||||
"""Create a task using the legacy ``{state, msg, data}`` response shape."""
|
||||
row = await _store(request).create_task(
|
||||
{
|
||||
**body.model_dump(),
|
||||
"task_name": body.task_name.strip(),
|
||||
"overview": body.overview.strip(),
|
||||
"created_by": _request_user_id(request),
|
||||
}
|
||||
)
|
||||
return {"state": 201, "msg": "创建任务成功", "data": row}
|
||||
|
||||
|
||||
def _import_task_rows(payload: Any) -> list[Any]:
|
||||
"""Accept array, ``{tasks: [...]}``, or legacy list-response envelopes."""
|
||||
if isinstance(payload, list):
|
||||
rows = payload
|
||||
elif isinstance(payload, dict):
|
||||
data = payload.get("tasks", payload.get("records", payload.get("data", payload)))
|
||||
if isinstance(data, dict):
|
||||
data = data.get("records", data.get("tasks", data))
|
||||
rows = data if isinstance(data, list) else [data]
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="任务导入文件必须是 JSON 数组或包含 tasks/records 的对象")
|
||||
|
||||
if not rows or not all(isinstance(row, dict) for row in rows):
|
||||
raise HTTPException(status_code=422, detail="任务导入文件中至少需要一条任务对象")
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/api/taskcop/import/tasks")
|
||||
async def import_tasks(request: Request, payload: Any = Body(...)) -> dict:
|
||||
"""Import tasks by id; a duplicate id atomically replaces that task."""
|
||||
rows = _import_task_rows(payload)
|
||||
created = 0
|
||||
updated = 0
|
||||
records: list[dict[str, Any]] = []
|
||||
for index, raw in enumerate(rows, start=1):
|
||||
try:
|
||||
item = TaskImportRecord.model_validate(raw)
|
||||
except ValidationError as error:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={"message": f"第 {index} 条任务数据格式不正确", "errors": error.errors()},
|
||||
) from error
|
||||
record, inserted = await _store(request).upsert_task(
|
||||
{
|
||||
**item.model_dump(),
|
||||
"task_name": item.task_name,
|
||||
"overview": item.overview,
|
||||
"created_by": _request_user_id(request),
|
||||
}
|
||||
)
|
||||
records.append(record)
|
||||
created += int(inserted)
|
||||
updated += int(not inserted)
|
||||
|
||||
return {
|
||||
"state": 200,
|
||||
"msg": "任务导入成功",
|
||||
"data": {"created": created, "updated": updated, "records": records},
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/cop/tasks/{task_id}")
|
||||
async def delete_task(request: Request, task_id: str) -> dict:
|
||||
"""Delete a TaskCOP task and its shared report document."""
|
||||
task_id = task_id.strip()
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=422, detail="task id is required")
|
||||
|
||||
task_store = _store(request)
|
||||
if await task_store.get_task(task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
if not await task_store.delete_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
|
||||
report_store = getattr(request.app.state, "task_report_store", None)
|
||||
if report_store is not None:
|
||||
await report_store.delete_by_task(task_id)
|
||||
return {"state": 200, "msg": "task deleted", "data": {"id": task_id}}
|
||||
|
||||
|
||||
@router.get("/taskAnalyseSearch/cop-task-three-list")
|
||||
async def list_task_three(
|
||||
request: Request,
|
||||
content: TaskListQuery = "",
|
||||
pageNum: int = Query(default=1),
|
||||
pageSize: int = Query(default=10),
|
||||
taskStatus: str | None = Query(default=None),
|
||||
taskDirection: str | None = Query(default=None),
|
||||
startTime: datetime | None = Query(default=None),
|
||||
endTime: datetime | None = Query(default=None),
|
||||
) -> dict:
|
||||
"""List all tasks for the sentiment screen's default "全部" tab."""
|
||||
return await _list_tasks(
|
||||
request,
|
||||
mine=False,
|
||||
content=content,
|
||||
page_num=pageNum,
|
||||
page_size=pageSize,
|
||||
task_status=taskStatus,
|
||||
task_direction=taskDirection,
|
||||
start_time=startTime,
|
||||
end_time=endTime,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/taskAnalyseSearch/cop-my-tasks")
|
||||
async def list_my_tasks(
|
||||
request: Request,
|
||||
content: TaskListQuery = "",
|
||||
pageNum: int = Query(default=1),
|
||||
pageSize: int = Query(default=10),
|
||||
taskStatus: str | None = Query(default=None),
|
||||
taskDirection: str | None = Query(default=None),
|
||||
startTime: datetime | None = Query(default=None),
|
||||
endTime: datetime | None = Query(default=None),
|
||||
) -> dict:
|
||||
"""List the caller's tasks for the sentiment screen's "我的" tab."""
|
||||
return await _list_tasks(
|
||||
request,
|
||||
mine=True,
|
||||
content=content,
|
||||
page_num=pageNum,
|
||||
page_size=pageSize,
|
||||
task_status=taskStatus,
|
||||
task_direction=taskDirection,
|
||||
start_time=startTime,
|
||||
end_time=endTime,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/taskAnalyseSearch/cop-task-list")
|
||||
async def list_task_analysis(
|
||||
request: Request,
|
||||
content: TaskListQuery = "",
|
||||
pageNum: int = Query(default=1),
|
||||
pageSize: int = Query(default=10),
|
||||
taskStatus: str | None = Query(default=None),
|
||||
taskDirection: str | None = Query(default=None),
|
||||
startTime: datetime | None = Query(default=None),
|
||||
endTime: datetime | None = Query(default=None),
|
||||
) -> dict:
|
||||
"""Compatibility alias used by the neighbouring task-analysis screen."""
|
||||
return await _list_tasks(
|
||||
request,
|
||||
mine=False,
|
||||
content=content,
|
||||
page_num=pageNum,
|
||||
page_size=pageSize,
|
||||
task_status=taskStatus,
|
||||
task_direction=taskDirection,
|
||||
start_time=startTime,
|
||||
end_time=endTime,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/taskAnalyseSearch/cop-task-detail")
|
||||
async def get_task_detail(request: Request, taskId: str = Query(min_length=1, max_length=64)) -> dict:
|
||||
"""Return a task selected in the list or opened via a task link."""
|
||||
row = await _store(request).get_task(taskId)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return {"state": 200, "msg": "查询成功", "data": row}
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Authenticated proxy for WeKnora source-context previews.
|
||||
|
||||
The browser may ask for the source behind a DeerFlow skill citation, but it
|
||||
must never receive the narrow WeKnora publication token. This router reads
|
||||
the same server-side MCP configuration as the retrieval skill and forwards
|
||||
only an authenticated, read-only context request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from deerflow.config.extensions_config import get_extensions_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/weknora", tags=["weknora"])
|
||||
|
||||
_MCP_SERVER_NAME = "weknora-retrieval"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WeKnoraConnection:
|
||||
base_url: str
|
||||
share_token: str
|
||||
timeout_seconds: float
|
||||
|
||||
|
||||
def _read_secret_file(path_value: str) -> str:
|
||||
try:
|
||||
value = Path(path_value).read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise RuntimeError("cannot read the WeKnora share-token file") from exc
|
||||
if not value:
|
||||
raise RuntimeError("the WeKnora share-token file is empty")
|
||||
return value
|
||||
|
||||
|
||||
def _connection_from_env(env: dict[str, str]) -> WeKnoraConnection:
|
||||
base_url = env.get("WEKNORA_BASE_URL", "").strip().rstrip("/")
|
||||
token_file = env.get("WEKNORA_DEERFLOW_SHARE_TOKEN_FILE", "").strip()
|
||||
if not base_url:
|
||||
raise RuntimeError("WEKNORA_BASE_URL is not configured")
|
||||
if not token_file:
|
||||
raise RuntimeError("WEKNORA_DEERFLOW_SHARE_TOKEN_FILE is not configured")
|
||||
try:
|
||||
timeout_seconds = float(
|
||||
env.get("WEKNORA_REQUEST_TIMEOUT_SECONDS", _DEFAULT_TIMEOUT_SECONDS),
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RuntimeError("invalid WeKnora request timeout") from exc
|
||||
if not 0 < timeout_seconds <= 60:
|
||||
raise RuntimeError("invalid WeKnora request timeout")
|
||||
return WeKnoraConnection(
|
||||
base_url=base_url,
|
||||
share_token=_read_secret_file(token_file),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def load_weknora_connection() -> WeKnoraConnection:
|
||||
config = get_extensions_config()
|
||||
server = config.mcp_servers.get(_MCP_SERVER_NAME)
|
||||
if server is None or not server.enabled:
|
||||
raise RuntimeError("the WeKnora retrieval integration is not enabled")
|
||||
return _connection_from_env(server.env)
|
||||
|
||||
|
||||
async def fetch_source_context(
|
||||
client: httpx.AsyncClient,
|
||||
connection: WeKnoraConnection,
|
||||
knowledge_base_id: str,
|
||||
chunk_id: str,
|
||||
) -> dict:
|
||||
"""Fetch one context window and return only the WeKnora response data."""
|
||||
path = (
|
||||
"/api/v1/deerflow/knowledge-bases/"
|
||||
f"{quote(knowledge_base_id, safe='')}/chunks/"
|
||||
f"{quote(chunk_id, safe='')}/context"
|
||||
)
|
||||
response = await client.get(
|
||||
f"{connection.base_url}{path}",
|
||||
headers={"X-DeerFlow-Share-Token": connection.share_token},
|
||||
)
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="The original source is no longer available")
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"WeKnora source context returned HTTP {response.status_code}")
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("WeKnora source context returned invalid JSON") from exc
|
||||
if not isinstance(payload, dict) or payload.get("success") is not True:
|
||||
raise RuntimeError("WeKnora source context was rejected")
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict) or not isinstance(data.get("chunks"), list):
|
||||
raise RuntimeError("WeKnora source context returned an invalid payload")
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/source-context", summary="Preview the original context of a WeKnora retrieval hit")
|
||||
async def get_source_context(
|
||||
knowledge_base_id: str = Query(..., min_length=1, max_length=128),
|
||||
chunk_id: str = Query(..., min_length=1, max_length=128),
|
||||
) -> dict:
|
||||
"""Return nearby original chunks for a result cited by the WeKnora skill."""
|
||||
try:
|
||||
connection = load_weknora_connection()
|
||||
except RuntimeError:
|
||||
logger.exception("WeKnora source-context proxy is not configured")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Original source preview is temporarily unavailable",
|
||||
) from None
|
||||
|
||||
try:
|
||||
timeout = httpx.Timeout(connection.timeout_seconds)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
data = await fetch_source_context(
|
||||
client,
|
||||
connection,
|
||||
knowledge_base_id,
|
||||
chunk_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except httpx.HTTPError:
|
||||
logger.warning("WeKnora source-context request failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Original source preview is temporarily unavailable",
|
||||
) from None
|
||||
except RuntimeError:
|
||||
logger.warning("WeKnora source-context response was invalid", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Original source preview is temporarily unavailable",
|
||||
) from None
|
||||
|
||||
return {"success": True, "data": data}
|
||||
@@ -1,5 +1,24 @@
|
||||
{
|
||||
"mcpServers": {},
|
||||
"mcpServers": {
|
||||
"weknora-retrieval": {
|
||||
"enabled": true,
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"skills/custom/weknora-knowledge-search/scripts/weknora_retrieval_mcp.py"
|
||||
],
|
||||
"env": {
|
||||
"WEKNORA_BASE_URL": "http://[::1]:8081",
|
||||
"WEKNORA_DEERFLOW_SHARE_TOKEN_FILE": "F:\\react01\\deeflow-code\\deerflow-server\\offline-backend-20260512\\backend\\.runtime\\secrets\\weknora_deerflow_share_token",
|
||||
"WEKNORA_ALLOWED_KB_IDS": "",
|
||||
"WEKNORA_REQUEST_TIMEOUT_SECONDS": "30",
|
||||
"WEKNORA_RERANK_URL": "http://47.88.25.99:7022"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skills": {
|
||||
"web-research": {
|
||||
"enabled": true
|
||||
@@ -30,6 +49,9 @@
|
||||
},
|
||||
"browser_act": {
|
||||
"enabled": true
|
||||
},
|
||||
"weknora-knowledge-search": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -69,6 +69,8 @@ from deerflow.persistence.scheduled_tasks.model import (
|
||||
)
|
||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
|
||||
from deerflow.persistence.task_buttons.model import TaskButtonRow
|
||||
from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow
|
||||
from deerflow.persistence.task_reports.model import TaskReportRow
|
||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||
@@ -129,6 +131,8 @@ __all__ = [
|
||||
"TagAssignmentRow",
|
||||
"TagRow",
|
||||
"TaskButtonRow",
|
||||
"TaskCopTaskRow",
|
||||
"TaskReportRow",
|
||||
"EmbedSessionRow",
|
||||
"ThreadMetaRow",
|
||||
"ThreadShareRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Task-scoped situation-report persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.task_reports.base import TaskReportStore
|
||||
from deerflow.persistence.task_reports.memory import MemoryTaskReportStore
|
||||
from deerflow.persistence.task_reports.model import TaskReportRow
|
||||
from deerflow.persistence.task_reports.sql import TaskReportRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryTaskReportStore",
|
||||
"TaskReportRepository",
|
||||
"TaskReportRow",
|
||||
"TaskReportStore",
|
||||
"make_task_report_store",
|
||||
]
|
||||
|
||||
|
||||
def make_task_report_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryTaskReportStore()
|
||||
return TaskReportRepository(session_factory)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Persistence contract for task-scoped situation-report details.
|
||||
|
||||
Each task owns one shared report document. The document stores the exact
|
||||
``data`` array consumed by the dashboard: each item's ``contentJson`` remains
|
||||
a JSON *string* rather than being parsed into a database JSON column.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TaskReportStore(ABC):
|
||||
"""Shared task-report document store (no user-level partitioning)."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Return the stored document or ``None`` when the task has no report."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def create_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Create a document, returning ``None`` if that task already exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def update_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Replace a document's records, or return ``None`` when absent."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_by_task(self, task_id: str) -> bool:
|
||||
"""Delete a task's report document and return whether it existed."""
|
||||
raise NotImplementedError
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""In-memory task-report store for deployments without a SQL database."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.task_reports.base import TaskReportStore
|
||||
|
||||
|
||||
class MemoryTaskReportStore(TaskReportStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(task_id)
|
||||
return deepcopy(row) if row is not None else None
|
||||
|
||||
async def create_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if task_id in self._rows:
|
||||
return None
|
||||
now = datetime.now(UTC).isoformat()
|
||||
row = {
|
||||
"task_id": task_id,
|
||||
"records": deepcopy(records),
|
||||
"updated_by": updated_by,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
self._rows[task_id] = row
|
||||
return deepcopy(row)
|
||||
|
||||
async def update_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
row = self._rows.get(task_id)
|
||||
if row is None:
|
||||
return None
|
||||
row["records"] = deepcopy(records)
|
||||
row["updated_by"] = updated_by
|
||||
row["updated_at"] = datetime.now(UTC).isoformat()
|
||||
return deepcopy(row)
|
||||
|
||||
async def delete_by_task(self, task_id: str) -> bool:
|
||||
return self._rows.pop(task_id, None) is not None
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""ORM model for task-scoped situation-report details."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class TaskReportRow(Base):
|
||||
"""One complete situation report document for one task id.
|
||||
|
||||
``records_json`` is serialized explicitly by the repository. Keeping it
|
||||
as long text preserves every item exactly as the existing Consumer API
|
||||
returns it, including the nested ``contentJson`` JSON string.
|
||||
"""
|
||||
|
||||
__tablename__ = "task_reports"
|
||||
|
||||
task_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
records_json: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="[]")
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), 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),
|
||||
)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"""SQLAlchemy-backed task-report document store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.task_reports.base import TaskReportStore
|
||||
from deerflow.persistence.task_reports.model import TaskReportRow
|
||||
|
||||
|
||||
def _decode_records(raw: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _encode_records(records: list[dict[str, Any]]) -> str:
|
||||
# ``contentJson`` is already a string in each record, so json.dumps keeps
|
||||
# it escaped exactly as required by the existing frontend contract.
|
||||
return json.dumps(records, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _to_dict(row: TaskReportRow) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": row.task_id,
|
||||
"records": _decode_records(row.records_json),
|
||||
"updated_by": row.updated_by,
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class TaskReportRepository(TaskReportStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskReportRow, task_id)
|
||||
return _to_dict(row) if row is not None else None
|
||||
|
||||
async def create_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
if await session.get(TaskReportRow, task_id) is not None:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
row = TaskReportRow(
|
||||
task_id=task_id,
|
||||
records_json=_encode_records(records),
|
||||
updated_by=updated_by,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def update_by_task(
|
||||
self,
|
||||
task_id: str,
|
||||
records: list[dict[str, Any]],
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskReportRow, task_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.records_json = _encode_records(records)
|
||||
row.updated_by = updated_by
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _to_dict(row)
|
||||
|
||||
async def delete_by_task(self, task_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskReportRow, task_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""TaskCOP situation-overview compatibility task persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.taskcop_tasks.base import TaskCopTaskStore
|
||||
from deerflow.persistence.taskcop_tasks.memory import MemoryTaskCopTaskStore
|
||||
from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow
|
||||
from deerflow.persistence.taskcop_tasks.sql import TaskCopTaskRepository
|
||||
|
||||
__all__ = [
|
||||
"MemoryTaskCopTaskStore",
|
||||
"TaskCopTaskRepository",
|
||||
"TaskCopTaskRow",
|
||||
"TaskCopTaskStore",
|
||||
"make_taskcop_task_store",
|
||||
]
|
||||
|
||||
|
||||
def make_taskcop_task_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemoryTaskCopTaskStore()
|
||||
return TaskCopTaskRepository(session_factory)
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"""Persistence contract for the temporary TaskCOP task bridge.
|
||||
|
||||
The legacy situation-overview frontend distinguishes the global task list from
|
||||
"my tasks". Task data is shared, while ``created_by`` is retained for the
|
||||
per-user filter and as an audit field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
TASK_STATUS_ANALYSIS_COMPLETED = 25
|
||||
|
||||
|
||||
class TaskCopTaskStore(ABC):
|
||||
@abstractmethod
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Persist one task and return its legacy-compatible record."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def upsert_task(self, data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Create or completely replace one task identified by ``data['id']``.
|
||||
|
||||
Returns the legacy-compatible record and whether a new row was created.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
created_by: str | None = None,
|
||||
keyword: str = "",
|
||||
task_direction: str | None = None,
|
||||
task_statuses: set[int] | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page_num: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""Return ``(total, records)`` using TaskCOP list semantics."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
"""Return a legacy-compatible task record, or ``None`` when absent."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete one task and return whether it existed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def mark_analysis_completed(self, task_id: str) -> bool:
|
||||
"""Move an existing task to the completed-analysis status (25)."""
|
||||
raise NotImplementedError
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
"""In-memory TaskCOP task store for memory-mode deployments and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.taskcop_tasks.base import TASK_STATUS_ANALYSIS_COMPLETED, TaskCopTaskStore
|
||||
|
||||
|
||||
def _as_legacy(row: dict[str, Any]) -> dict[str, Any]:
|
||||
def iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"taskId": row["id"],
|
||||
"taskName": row["task_name"],
|
||||
"taskContent": row["overview"],
|
||||
"overview": row["overview"],
|
||||
"taskDirection": row["task_direction"],
|
||||
"taskLevel": row["task_level"],
|
||||
"taskStatus": row["task_status"],
|
||||
"trendSucc": row["task_status"] >= TASK_STATUS_ANALYSIS_COMPLETED,
|
||||
"taskType": row["task_type"],
|
||||
"taskSuperiorFlag": row["task_superior_flag"],
|
||||
"pTaskId": row.get("parent_task_id"),
|
||||
"issuingDept": row["issuing_dept"],
|
||||
"taskNo": row["task_no"],
|
||||
"taskStartDate": iso(row.get("task_start_date")),
|
||||
"taskEndDate": iso(row.get("task_end_date")),
|
||||
"sourceType": 0,
|
||||
"ifCreateTask": 0,
|
||||
"sumAll": 0,
|
||||
"endCount": 0,
|
||||
"createTime": iso(row["create_time"]),
|
||||
"updateTime": iso(row["update_time"]),
|
||||
}
|
||||
|
||||
|
||||
class MemoryTaskCopTaskStore(TaskCopTaskStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
task_id = str(data.get("id") or self._next_task_id())
|
||||
row = {
|
||||
"id": task_id,
|
||||
"task_name": str(data["task_name"]),
|
||||
"overview": str(data.get("overview") or ""),
|
||||
"task_level": str(data.get("task_level") or "1"),
|
||||
"task_direction": str(data.get("task_direction") or "W方向"),
|
||||
"task_status": int(data.get("task_status", 0)),
|
||||
"task_type": int(data.get("task_type", 1)),
|
||||
"task_superior_flag": int(data.get("task_superior_flag", 0)),
|
||||
"parent_task_id": data.get("parent_task_id") or None,
|
||||
"issuing_dept": str(data.get("issuing_dept") or ""),
|
||||
"task_no": str(data.get("task_no") or ""),
|
||||
"created_by": str(data.get("created_by") or "default"),
|
||||
"task_start_date": data.get("task_start_date"),
|
||||
"task_end_date": data.get("task_end_date"),
|
||||
"create_time": now,
|
||||
"update_time": now,
|
||||
}
|
||||
self._rows[row["id"]] = row
|
||||
return _as_legacy(row)
|
||||
|
||||
async def upsert_task(self, data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Import a task with a caller-provided id, replacing any existing row."""
|
||||
task_id = str(data["id"])
|
||||
previous = self._rows.get(task_id)
|
||||
now = datetime.now(UTC)
|
||||
row = {
|
||||
"id": task_id,
|
||||
"task_name": str(data["task_name"]),
|
||||
"overview": str(data.get("overview") or ""),
|
||||
"task_level": str(data.get("task_level") or "1"),
|
||||
"task_direction": str(data.get("task_direction") or "W方向"),
|
||||
"task_status": int(data.get("task_status", 0)),
|
||||
"task_type": int(data.get("task_type", 1)),
|
||||
"task_superior_flag": int(data.get("task_superior_flag", 0)),
|
||||
"parent_task_id": data.get("parent_task_id") or None,
|
||||
"issuing_dept": str(data.get("issuing_dept") or ""),
|
||||
"task_no": str(data.get("task_no") or ""),
|
||||
"created_by": str(data.get("created_by") or "default"),
|
||||
"task_start_date": data.get("task_start_date"),
|
||||
"task_end_date": data.get("task_end_date"),
|
||||
"create_time": previous["create_time"] if previous is not None else now,
|
||||
"update_time": now,
|
||||
}
|
||||
self._rows[task_id] = row
|
||||
return _as_legacy(row), previous is None
|
||||
|
||||
def _next_task_id(self) -> str:
|
||||
"""Return the next four-digit TaskCOP id, ignoring legacy UUID rows."""
|
||||
numeric_ids = (int(task_id) for task_id in self._rows if task_id.isdecimal())
|
||||
return f"{max(numeric_ids, default=0) + 1:04d}"
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
created_by: str | None = None,
|
||||
keyword: str = "",
|
||||
task_direction: str | None = None,
|
||||
task_statuses: set[int] | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page_num: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
needle = keyword.strip().casefold()
|
||||
rows = list(self._rows.values())
|
||||
if created_by is not None:
|
||||
rows = [row for row in rows if row["created_by"] == created_by]
|
||||
if needle:
|
||||
rows = [
|
||||
row
|
||||
for row in rows
|
||||
if needle in row["task_name"].casefold()
|
||||
or needle in row["overview"].casefold()
|
||||
or needle in row["task_no"].casefold()
|
||||
]
|
||||
if task_direction:
|
||||
rows = [row for row in rows if row["task_direction"] == task_direction]
|
||||
if task_statuses:
|
||||
rows = [row for row in rows if row["task_status"] in task_statuses]
|
||||
if start_time:
|
||||
rows = [row for row in rows if row["create_time"] >= start_time]
|
||||
if end_time:
|
||||
rows = [row for row in rows if row["create_time"] <= end_time]
|
||||
rows.sort(key=lambda row: row["create_time"], reverse=True)
|
||||
total = len(rows)
|
||||
offset = max(page_num - 1, 0) * page_size
|
||||
return total, [_as_legacy(row) for row in rows[offset : offset + page_size]]
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
row = self._rows.get(task_id)
|
||||
return _as_legacy(row) if row is not None else None
|
||||
|
||||
async def delete_task(self, task_id: str) -> bool:
|
||||
return self._rows.pop(task_id, None) is not None
|
||||
|
||||
async def mark_analysis_completed(self, task_id: str) -> bool:
|
||||
row = self._rows.get(task_id)
|
||||
if row is None:
|
||||
return False
|
||||
if row["task_status"] < TASK_STATUS_ANALYSIS_COMPLETED:
|
||||
row["task_status"] = TASK_STATUS_ANALYSIS_COMPLETED
|
||||
row["update_time"] = datetime.now(UTC)
|
||||
return True
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""ORM model for TaskCOP compatibility tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
||||
|
||||
|
||||
class TaskCopTaskRow(Base):
|
||||
"""A task created from the legacy situation-overview screen."""
|
||||
|
||||
__tablename__ = "taskcop_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
task_name: Mapped[str] = mapped_column(String(512), nullable=False, index=True)
|
||||
overview: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
|
||||
task_level: Mapped[str] = mapped_column(String(32), nullable=False, default="1")
|
||||
task_direction: Mapped[str] = mapped_column(String(64), nullable=False, default="W方向", index=True)
|
||||
task_status: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
||||
task_type: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
task_superior_flag: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
parent_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
issuing_dept: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
task_no: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
created_by: Mapped[str] = mapped_column(String(64), nullable=False, default="default", index=True)
|
||||
task_start_date: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||
task_end_date: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), index=True
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
"""SQLAlchemy-backed TaskCOP compatibility task store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.taskcop_tasks.base import TASK_STATUS_ANALYSIS_COMPLETED, TaskCopTaskStore
|
||||
from deerflow.persistence.taskcop_tasks.model import TaskCopTaskRow
|
||||
|
||||
|
||||
def _as_legacy(row: TaskCopTaskRow) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"taskId": row.id,
|
||||
"taskName": row.task_name,
|
||||
"taskContent": row.overview,
|
||||
"overview": row.overview,
|
||||
"taskDirection": row.task_direction,
|
||||
"taskLevel": row.task_level,
|
||||
"taskStatus": row.task_status,
|
||||
"trendSucc": row.task_status >= TASK_STATUS_ANALYSIS_COMPLETED,
|
||||
"taskType": row.task_type,
|
||||
"taskSuperiorFlag": row.task_superior_flag,
|
||||
"pTaskId": row.parent_task_id,
|
||||
"issuingDept": row.issuing_dept,
|
||||
"taskNo": row.task_no,
|
||||
"taskStartDate": row.task_start_date.isoformat() if row.task_start_date else None,
|
||||
"taskEndDate": row.task_end_date.isoformat() if row.task_end_date else None,
|
||||
"sourceType": 0,
|
||||
"ifCreateTask": 0,
|
||||
"sumAll": 0,
|
||||
"endCount": 0,
|
||||
"createTime": row.create_time.isoformat(),
|
||||
"updateTime": row.update_time.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class TaskCopTaskRepository(TaskCopTaskStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def create_task(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
for _ in range(10):
|
||||
try:
|
||||
return await self._create_task_once(data)
|
||||
except IntegrityError:
|
||||
if data.get("id") is not None:
|
||||
raise
|
||||
raise RuntimeError("Failed to allocate a TaskCOP task id after multiple attempts")
|
||||
|
||||
async def _create_task_once(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
task_id = str(data.get("id") or await self._next_task_id())
|
||||
row = TaskCopTaskRow(
|
||||
id=task_id,
|
||||
task_name=str(data["task_name"]),
|
||||
overview=str(data.get("overview") or ""),
|
||||
task_level=str(data.get("task_level") or "1"),
|
||||
task_direction=str(data.get("task_direction") or "W方向"),
|
||||
task_status=int(data.get("task_status", 0)),
|
||||
task_type=int(data.get("task_type", 1)),
|
||||
task_superior_flag=int(data.get("task_superior_flag", 0)),
|
||||
parent_task_id=data.get("parent_task_id") or None,
|
||||
issuing_dept=str(data.get("issuing_dept") or ""),
|
||||
task_no=str(data.get("task_no") or ""),
|
||||
created_by=str(data.get("created_by") or "default"),
|
||||
task_start_date=data.get("task_start_date"),
|
||||
task_end_date=data.get("task_end_date"),
|
||||
)
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _as_legacy(row)
|
||||
|
||||
async def upsert_task(self, data: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Import a caller-supplied task id, fully replacing an existing row."""
|
||||
task_id = str(data["id"])
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskCopTaskRow, task_id)
|
||||
created = row is None
|
||||
if row is None:
|
||||
row = TaskCopTaskRow(id=task_id, task_name=str(data["task_name"]))
|
||||
session.add(row)
|
||||
|
||||
row.task_name = str(data["task_name"])
|
||||
row.overview = str(data.get("overview") or "")
|
||||
row.task_level = str(data.get("task_level") or "1")
|
||||
row.task_direction = str(data.get("task_direction") or "W方向")
|
||||
row.task_status = int(data.get("task_status", 0))
|
||||
row.task_type = int(data.get("task_type", 1))
|
||||
row.task_superior_flag = int(data.get("task_superior_flag", 0))
|
||||
row.parent_task_id = data.get("parent_task_id") or None
|
||||
row.issuing_dept = str(data.get("issuing_dept") or "")
|
||||
row.task_no = str(data.get("task_no") or "")
|
||||
row.created_by = str(data.get("created_by") or "default")
|
||||
row.task_start_date = data.get("task_start_date")
|
||||
row.task_end_date = data.get("task_end_date")
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return _as_legacy(row), created
|
||||
|
||||
async def _next_task_id(self) -> str:
|
||||
"""Return the next four-digit id, skipping historical UUID task rows."""
|
||||
async with self._sf() as session:
|
||||
task_ids = (await session.scalars(select(TaskCopTaskRow.id))).all()
|
||||
numeric_ids = (int(task_id) for task_id in task_ids if task_id.isdecimal())
|
||||
return f"{max(numeric_ids, default=0) + 1:04d}"
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
*,
|
||||
created_by: str | None = None,
|
||||
keyword: str = "",
|
||||
task_direction: str | None = None,
|
||||
task_statuses: set[int] | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page_num: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
filters = []
|
||||
if created_by is not None:
|
||||
filters.append(TaskCopTaskRow.created_by == created_by)
|
||||
if keyword.strip():
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
TaskCopTaskRow.task_name.ilike(pattern),
|
||||
TaskCopTaskRow.overview.ilike(pattern),
|
||||
TaskCopTaskRow.task_no.ilike(pattern),
|
||||
)
|
||||
)
|
||||
if task_direction:
|
||||
filters.append(TaskCopTaskRow.task_direction == task_direction)
|
||||
if task_statuses:
|
||||
filters.append(TaskCopTaskRow.task_status.in_(task_statuses))
|
||||
if start_time:
|
||||
filters.append(TaskCopTaskRow.create_time >= start_time)
|
||||
if end_time:
|
||||
filters.append(TaskCopTaskRow.create_time <= end_time)
|
||||
|
||||
offset = max(page_num - 1, 0) * page_size
|
||||
async with self._sf() as session:
|
||||
total = await session.scalar(select(func.count()).select_from(TaskCopTaskRow).where(*filters))
|
||||
stmt = (
|
||||
select(TaskCopTaskRow)
|
||||
.where(*filters)
|
||||
.order_by(TaskCopTaskRow.create_time.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(total or 0), [_as_legacy(row) for row in result.scalars()]
|
||||
|
||||
async def get_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskCopTaskRow, task_id)
|
||||
return _as_legacy(row) if row is not None else None
|
||||
|
||||
async def delete_task(self, task_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskCopTaskRow, task_id)
|
||||
if row is None:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def mark_analysis_completed(self, task_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(TaskCopTaskRow, task_id)
|
||||
if row is None:
|
||||
return False
|
||||
if row.task_status < TASK_STATUS_ANALYSIS_COMPLETED:
|
||||
row.task_status = TASK_STATUS_ANALYSIS_COMPLETED
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,172 @@
|
||||
# DeerFlow 后台离线镜像部署简明指南
|
||||
|
||||
本文档只部署后台 Docker 镜像,不包含前端。
|
||||
|
||||
## 1. 部署前准备
|
||||
|
||||
目标服务器需要已安装并启动 Docker:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker info
|
||||
```
|
||||
|
||||
将交付目录 `deerflow-backend` 放到服务器任意位置。以下统一以 `/data/deerflow-backend` 为例:
|
||||
|
||||
```bash
|
||||
DEPLOY_DIR=/data/deerflow-backend
|
||||
cd "$DEPLOY_DIR"
|
||||
chmod +x start-backend-docker.sh
|
||||
```
|
||||
|
||||
如果交付的是压缩包,解压后确保目录中能看到以下文件:
|
||||
|
||||
```text
|
||||
dist/deerflow-backend-offline.tar
|
||||
start-backend-docker.sh
|
||||
config.yaml
|
||||
```
|
||||
|
||||
## 2. 导入镜像并启动
|
||||
|
||||
首次部署执行一次镜像导入:
|
||||
|
||||
```bash
|
||||
cd "$DEPLOY_DIR"
|
||||
docker load -i dist/deerflow-backend-offline.tar
|
||||
```
|
||||
|
||||
启动后台,默认端口为 `8001`:
|
||||
|
||||
```bash
|
||||
./start-backend-docker.sh 8001
|
||||
```
|
||||
|
||||
例如使用 `9001` 端口:
|
||||
|
||||
```bash
|
||||
./start-backend-docker.sh 9001
|
||||
```
|
||||
|
||||
以后重启只需要再次执行同一条启动命令。脚本会自动删除旧容器并创建新容器:
|
||||
|
||||
```bash
|
||||
./start-backend-docker.sh 8001
|
||||
```
|
||||
|
||||
查看运行状态和日志:
|
||||
|
||||
```bash
|
||||
docker ps --filter name=deerflow-backend
|
||||
docker logs -f deerflow-backend
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8001/health
|
||||
```
|
||||
|
||||
## 3. 必须知道的文件位置
|
||||
|
||||
首次启动后,实际生效的配置和数据均在 `docker-data` 中。不要只修改部署根目录的模板文件。
|
||||
|
||||
```text
|
||||
$DEPLOY_DIR/docker-data/config/config.yaml # 主配置
|
||||
$DEPLOY_DIR/docker-data/config/.env # 密码、地址等环境变量
|
||||
$DEPLOY_DIR/docker-data/config/extensions_config.json # MCP/扩展配置
|
||||
$DEPLOY_DIR/docker-data/.deer-flow/agents # 智能体及每个 SOUL.md
|
||||
$DEPLOY_DIR/docker-data/skills # 技能目录
|
||||
$DEPLOY_DIR/docker-data/.deer-flow/data # SQLite 数据
|
||||
```
|
||||
|
||||
修改 `config.yaml`、`.env`、智能体或技能后,重新执行启动命令即可生效:
|
||||
|
||||
```bash
|
||||
cd "$DEPLOY_DIR"
|
||||
./start-backend-docker.sh 8001
|
||||
```
|
||||
|
||||
## 4. 配置 MySQL
|
||||
|
||||
本部署支持 MySQL 保存业务数据,同时 SQLite 保留聊天/checkpointer 数据,不使用 PostgreSQL。
|
||||
|
||||
先在 MySQL 中创建数据库:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE deerflow CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
编辑 `$DEPLOY_DIR/docker-data/config/config.yaml`,将数据库部分改为:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
backend: mysql
|
||||
mysql_url: $MYSQL_DATABASE_URL
|
||||
pool_size: 20
|
||||
max_overflow: 20
|
||||
pool_timeout: 30
|
||||
echo_sql: false
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
```
|
||||
|
||||
再编辑 `$DEPLOY_DIR/docker-data/config/.env`,填写 MySQL 地址:
|
||||
|
||||
```bash
|
||||
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:数据库密码@192.168.1.10:3306/deerflow?charset=utf8mb4
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
```text
|
||||
deerflow MySQL 用户名
|
||||
数据库密码 MySQL 密码
|
||||
192.168.1.10 MySQL 服务器地址
|
||||
3306 MySQL 端口
|
||||
deerflow MySQL 数据库名
|
||||
```
|
||||
|
||||
若 MySQL 安装在 Docker 宿主机上,可把地址写为 `host.docker.internal`:
|
||||
|
||||
```bash
|
||||
MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:数据库密码@host.docker.internal:3306/deerflow?charset=utf8mb4
|
||||
```
|
||||
|
||||
密码中有特殊字符时必须进行 URL 编码,例如:`@` 写为 `%40`,`#` 写为 `%23`,`%` 写为 `%25`。
|
||||
|
||||
配置完成后启动:
|
||||
|
||||
```bash
|
||||
cd "$DEPLOY_DIR"
|
||||
./start-backend-docker.sh 8001
|
||||
```
|
||||
|
||||
## 5. 导入智能体和技能
|
||||
|
||||
停止服务后复制文件,复制完成再启动。智能体目录中每个智能体必须同时保留 `config.yaml` 和 `SOUL.md`。
|
||||
|
||||
```bash
|
||||
docker rm -f deerflow-backend
|
||||
|
||||
cp -a /path/to/agents/. "$DEPLOY_DIR/docker-data/.deer-flow/agents/"
|
||||
cp -a /path/to/skills/. "$DEPLOY_DIR/docker-data/skills/"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
./start-backend-docker.sh 8001
|
||||
```
|
||||
|
||||
复制时使用 `源目录/.` 的形式,避免产生 `agents/智能体ID/智能体ID/SOUL.md` 的多一层目录问题。
|
||||
|
||||
## 6. SQLite 聊天数据
|
||||
|
||||
聊天和 checkpointer 的 SQLite 文件位于:
|
||||
|
||||
```text
|
||||
$DEPLOY_DIR/docker-data/.deer-flow/data/deerflow.db
|
||||
```
|
||||
|
||||
替换 SQLite 数据库前必须先停止容器,并同时复制 `deerflow.db`、`deerflow.db-wal`、`deerflow.db-shm`(存在时)。替换后再执行启动命令。
|
||||
|
||||
+4
-1
@@ -25,7 +25,10 @@ HOST_IP="${HOST_IP:-127.0.0.1}"
|
||||
BACKEND_PUBLIC_BASE_URL="${BACKEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${BACKEND_PORT}}"
|
||||
FRONTEND_PUBLIC_BASE_URL="${FRONTEND_PUBLIC_BASE_URL:-http://${HOST_IP}:${FRONTEND_PORT}}"
|
||||
|
||||
GATEWAY_CORS_ORIGINS="${GATEWAY_CORS_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT},http://${HOST_IP}:${FRONTEND_PORT}}"
|
||||
# The standalone legacy TaskCOP UI runs on Vite :8080 and calls the Gateway
|
||||
# directly through its configured b1ConsumerUrl. Keep its local origins in the
|
||||
# backend CORS allow-list instead of relying on a frontend reverse proxy.
|
||||
GATEWAY_CORS_ORIGINS="${GATEWAY_CORS_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT},http://${HOST_IP}:${FRONTEND_PORT},http://localhost:8080,http://127.0.0.1:8080}"
|
||||
|
||||
mkdir -p "${LOG_DIR}" "${PID_DIR}"
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs Docker Engine in an Ubuntu WSL distribution. Run as root.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Run this script as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
if [[ "${ID}" != "ubuntu" ]]; then
|
||||
echo "This installer supports Ubuntu only (detected: ${ID})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
cat > /etc/apt/sources.list.d/docker.sources <<EOF
|
||||
Types: deb
|
||||
URIs: https://download.docker.com/linux/ubuntu
|
||||
Suites: ${VERSION_CODENAME}
|
||||
Components: stable
|
||||
Architectures: $(dpkg --print-architecture)
|
||||
Signed-By: /etc/apt/keyrings/docker.asc
|
||||
EOF
|
||||
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
containerd.io \
|
||||
docker-buildx-plugin \
|
||||
docker-compose-plugin
|
||||
|
||||
systemctl enable --now docker
|
||||
docker version --format 'Docker Engine {{.Server.Version}} is ready.'
|
||||
docker compose version
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app.py .
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
TEI_URL = os.getenv("TEI_URL", "http://tei-rerank:80").rstrip("/")
|
||||
app = FastAPI(title="WeKnora TEI Rerank Adapter")
|
||||
|
||||
|
||||
class RerankRequest(BaseModel):
|
||||
model: str = "bge-reranker-base"
|
||||
query: str
|
||||
documents: list[str] = Field(min_length=1)
|
||||
additional_data: dict[str, Any] = Field(default_factory=dict)
|
||||
truncate_prompt_tokens: int = 511
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/rerank")
|
||||
async def rerank(request: RerankRequest) -> dict[str, Any]:
|
||||
payload = {
|
||||
"query": request.query,
|
||||
"texts": request.documents,
|
||||
"raw_scores": False,
|
||||
"return_text": False,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(f"{TEI_URL}/rerank", json=payload)
|
||||
response.raise_for_status()
|
||||
scores = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"TEI rerank request failed: {exc}") from exc
|
||||
|
||||
results = []
|
||||
for item in scores:
|
||||
index = int(item["index"])
|
||||
results.append(
|
||||
{
|
||||
"index": index,
|
||||
"relevance_score": float(item["score"]),
|
||||
"document": {"text": request.documents[index]},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": "local-tei-rerank",
|
||||
"model": request.model,
|
||||
"usage": {"total_tokens": 0},
|
||||
"results": results,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
httpx==0.28.1
|
||||
Reference in new issue
Block a user