代码提交

This commit is contained in:
董宏凯 committed 2026-07-15 16:13:03 +08:00
1 parent 27e6b0b23e
commit d7ad0624b7
34 files changed
+2188 -281

No files matched your search

View File
Whitespace-only changes.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
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[],
+38 -207
View File
@@ -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;
}
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;
}
} catch {
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
}
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,
);
}
// 真实接口模式:**不兜底假数据**。失败/空 → 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");
}
@@ -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,
});
}
+2 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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
+12 -1
View File
@@ -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
}
}
}
@@ -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",
@@ -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)
@@ -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
@@ -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
@@ -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),
)
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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),
)
@@ -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
View File
@@ -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}"
+42
View File
@@ -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
+9
View File
@@ -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"]
+57
View File
@@ -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,
}
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.12
uvicorn[standard]==0.34.2
httpx==0.28.1