Merge branch 'xuhd' into 'master'
chore(env): 添加我的笔记相关环境变量配置 See merge request knowledge-base-bj/deerflow-server!111
This commit is contained in:
commit
5e68f81a1f
50 files changed
+3749
-951
No files matched your search
@@ -27,15 +27,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_BACKENP_URL=http://localhost:8003
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_FRONTEND_URL=http://localhost:3000
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -15,15 +15,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
|
||||
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
@@ -23,7 +23,6 @@
|
||||
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
|
||||
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
@@ -28,14 +28,16 @@ VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=http://ch1.b.uat.4.cn/knowes
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -24,18 +24,21 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
|
||||
# 我的空间前端
|
||||
VITE_AIPI_FRONTEND_URL= xxx
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=https://present.a.uat.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.uat.4.cn/knowes
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -5,4 +5,3 @@
|
||||
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
|
||||
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
|
||||
# VITE_STUDIO_ENABLED=true
|
||||
@@ -24,7 +24,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
@@ -32,10 +32,16 @@ VITE_STUDIO_ENABLED=true
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
|
||||
|
||||
# A 系统跳转地址(人物/组织/事件等列表项打开链接)
|
||||
VITE_AIPI_PRESENT_URL=http://present.a.sit.4.cn/present/domain
|
||||
# B 系统知识库 iframe 根地址
|
||||
VITE_AIPI_KNOWES_URL=https://ch1.b.sit.4.cn/knowes
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
|
||||
# 工作室/空间前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -74,7 +74,7 @@
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import Empty from "./empty";
|
||||
import { NetworkActor } from "./network-actor";
|
||||
|
||||
// ── 适配层 ─────────────────────────────────────────────────
|
||||
|
||||
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||
const token = getStoredAuth()?.access_token;
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getYUserId(): string {
|
||||
if (typeof window === "undefined") return "1157761008018653184";
|
||||
try {
|
||||
const raw = localStorage.getItem("deerflow.external_user_info");
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw) as { yUserId?: string };
|
||||
if (obj?.yUserId) return obj.yUserId;
|
||||
}
|
||||
} catch { }
|
||||
return "1157761008018653184";
|
||||
}
|
||||
|
||||
|
||||
|
||||
const searchTypes = [
|
||||
{ value: "human", label: "人物基因库" },
|
||||
{ value: "organization", label: "组织基因库" },
|
||||
{ value: "community", label: "群体基因库" },
|
||||
{ value: "issue", label: "议题基因库" },
|
||||
{ value: "event", label: "事件基因库" },
|
||||
{ value: "threat", label: "风险与机会" },
|
||||
] as const;
|
||||
|
||||
// ── Context ────────────────────────────────────────────────
|
||||
interface DocumentUploadDialogContextType {
|
||||
openDialog: (initialSearchText?: string) => void;
|
||||
closeDialog: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
// 上传成功回调,返回上传的来源信息
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}
|
||||
|
||||
// ── 任务轮询类型 ─────────────────────────────────────────────
|
||||
interface TaskSource {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: 'processing' | 'success' | 'error';
|
||||
source_type: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
skipped: number;
|
||||
sources: TaskSource[];
|
||||
}
|
||||
|
||||
interface listDataItem {
|
||||
id?: string | number;
|
||||
name?: string;
|
||||
redisKey?: string;
|
||||
abstractz?: string;
|
||||
checked: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
// 上传文件轮询相关字段
|
||||
task_id?: string;
|
||||
processing_status?: 'processing' | 'success' | 'error';
|
||||
is_uploaded_file?: boolean;
|
||||
country?: string;
|
||||
dateOfBirth?: string;
|
||||
occupationData?: string;
|
||||
party?: string;
|
||||
sexOrGender?: string;
|
||||
imagePath?: string;
|
||||
mongoId?: string;
|
||||
countryData?: string;
|
||||
countrySingle?: string;
|
||||
subtype?: string;
|
||||
text?: string;
|
||||
twoLevel?: string;
|
||||
account_ids?: unknown[];
|
||||
community_description?: string;
|
||||
community_name?: string;
|
||||
community_type?: string;
|
||||
create_time?: string;
|
||||
creator?: string;
|
||||
groups?: string;
|
||||
labels?: string;
|
||||
nations?: string;
|
||||
params?: string;
|
||||
platforms?: string;
|
||||
real_total?: number;
|
||||
total?: number;
|
||||
update_time?: string;
|
||||
virtual_total?: number;
|
||||
direction?: string;
|
||||
hot_num?: number;
|
||||
issue_hot?: number;
|
||||
issue_name?: string;
|
||||
issue_type?: string;
|
||||
news_num?: number;
|
||||
post_num?: number;
|
||||
topic?: string;
|
||||
abstract?: string;
|
||||
allwords?: string[];
|
||||
category?: string;
|
||||
controversy_num?: number;
|
||||
date?: string;
|
||||
discriminant_reason?: string;
|
||||
discriminant_result?: string;
|
||||
doc_author?: string;
|
||||
doc_ids?: string[];
|
||||
emotion_info?: string;
|
||||
first_publish_time?: string;
|
||||
grade?: string;
|
||||
human_id_list?: string;
|
||||
human_list?: string[];
|
||||
if_artificial?: number;
|
||||
if_follow?: number;
|
||||
if_shehua?: number;
|
||||
if_show?: number;
|
||||
image_path?: string;
|
||||
import_num?: number;
|
||||
insert_time?: string;
|
||||
jw?: string;
|
||||
keywords?: string[];
|
||||
location?: string[];
|
||||
location_list?: unknown[];
|
||||
location_type?: number;
|
||||
longitude?: number;
|
||||
media_board?: string;
|
||||
media_type?: string;
|
||||
nowords?: unknown[];
|
||||
onewords?: unknown[];
|
||||
organization_id_list?: string;
|
||||
organization_list?: unknown[];
|
||||
raw_id?: string;
|
||||
report_num?: number;
|
||||
site_name?: string;
|
||||
source_type?: string;
|
||||
sub_category?: string;
|
||||
subject?: string;
|
||||
super_category?: string;
|
||||
surge_num?: number;
|
||||
tone?: string;
|
||||
topic_id?: string;
|
||||
total_num?: number;
|
||||
type?: string;
|
||||
userid?: string;
|
||||
wx_sub_score?: string;
|
||||
wx_total_score?: string;
|
||||
}
|
||||
|
||||
const DocumentUploadDialogContext =
|
||||
createContext<DocumentUploadDialogContextType | null>(null);
|
||||
|
||||
export const useDocumentUploadDialog = () => {
|
||||
const context = useContext(DocumentUploadDialogContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDocumentUploadDialog must be used within DocumentUploadDialogProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────
|
||||
export const DocumentUploadDialogProvider: FC<{
|
||||
children: ReactNode;
|
||||
onClose?: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => void | Promise<void>;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialSearchText, setInitialSearchText] = useState("");
|
||||
const isClosingRef = useRef(false);
|
||||
|
||||
const openDialog = useCallback((initialSearchText?: string) => {
|
||||
if (isClosingRef.current) return;
|
||||
setInitialSearchText(initialSearchText || "");
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeDialog = useCallback(
|
||||
async (mode?: "search", data?: { query: string; mode: string }) => {
|
||||
isClosingRef.current = true;
|
||||
if (onClose) {
|
||||
try {
|
||||
await onClose(mode, data);
|
||||
} catch (error) {
|
||||
console.error("Error in Provider onClose:", error);
|
||||
}
|
||||
}
|
||||
setIsOpen(false);
|
||||
setTimeout(() => {
|
||||
isClosingRef.current = false;
|
||||
}, 300);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
if (!isClosingRef.current) {
|
||||
void closeDialog("search", { query: "", mode: "" });
|
||||
void closeDialog();
|
||||
}
|
||||
} else {
|
||||
if (!isClosingRef.current) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeDialog]
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentUploadDialogContext.Provider value={{ openDialog, closeDialog, onUploadComplete }}>
|
||||
{children}
|
||||
<DocumentUploadPopupContent
|
||||
isOpen={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
searchSpace={searchSpace}
|
||||
onUploadSuccess={onUploadSuccess}
|
||||
closeDialog={closeDialog}
|
||||
initialSearchText={initialSearchText}
|
||||
/>
|
||||
</DocumentUploadDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Popup content ──────────────────────────────────────────
|
||||
const UPLOAD_ACCEPT =
|
||||
".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||||
|
||||
const DocumentUploadPopupContent: FC<{
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
closeDialog: (
|
||||
mode?: "search",
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
initialSearchText?: string;
|
||||
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
|
||||
const { onUploadComplete } = useContext(DocumentUploadDialogContext) || {};
|
||||
const searchSpaceId = String(searchSpace?.id);
|
||||
|
||||
// 从环境变量获取配置
|
||||
const backendUrl = import.meta.env.VITE_AIPI_BACKENP_URL || "http://localhost:8001";
|
||||
const consumerUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || "http://ch1.b.uat.4.cn/consumer";
|
||||
const presentUrl = import.meta.env.VITE_AIPI_PRESENT_URL || "http://present.a.uat.4.cn/present/domain";
|
||||
const knowesUrl = import.meta.env.VITE_AIPI_KNOWES_URL || "http://ch1.b.uat.4.cn/knowes";
|
||||
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL || "http://chlib.b.uat.4.cn/knowlibrary";
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [listData, setListData] = useState<listDataItem[]>([]);
|
||||
const [searchType, setSearchType] = useState("human");
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<listDataItem[]>([]);
|
||||
// upload tab state
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialSearchText) {
|
||||
setSearchText(initialSearchText);
|
||||
setSearchValue(initialSearchText);
|
||||
}
|
||||
}, [isOpen, initialSearchText]);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
onUploadSuccess?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const searchActiveChange = async (type: "search" | "Y" | "upload" | "A" | "actor") => {
|
||||
setActiveTab(type);
|
||||
if (type === "A") {
|
||||
await searchAChange(searchType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const token = getStoredAuth()?.access_token ?? "";
|
||||
const formData = new FormData();
|
||||
formData.append("search_space_id", searchSpaceId);
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as UploadResponse;
|
||||
// 调用上传完成回调,传递来源信息
|
||||
if (data.sources && data.sources.length > 0) {
|
||||
onUploadComplete?.(data.sources.map(s => ({
|
||||
id: undefined, // 后端返回的数据中暂时没有 id
|
||||
title: s.title,
|
||||
task_id: s.task_id,
|
||||
status: s.status,
|
||||
})));
|
||||
}
|
||||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||||
// 只关闭上传弹窗
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error("上传失败,请重试");
|
||||
}
|
||||
} catch {
|
||||
toast.error("上传失败,请重试");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSearchSources.length === 0) {
|
||||
toast.warning("没有可导入的来源");
|
||||
return;
|
||||
}
|
||||
setIsImporting(true);
|
||||
const dataList = selectedSearchSources.map((item) => ({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
}));
|
||||
try {
|
||||
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
source_list: dataList,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { imported_count?: number };
|
||||
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||
setListData([]);
|
||||
handleBatchDelete();
|
||||
} else {
|
||||
const errorData = await response.json() as { detail?: string };
|
||||
toast.error(errorData.detail || "导入失败,请重试");
|
||||
}
|
||||
} catch {
|
||||
toast.error("导入失败,请重试");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||||
setListData((prevSources) => {
|
||||
const newSources = prevSources.map((item, aIndex) =>
|
||||
aIndex === index ? { ...item, checked } : item
|
||||
);
|
||||
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||||
return newSources;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
setListData((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
setSelectedSearchSources([]);
|
||||
};
|
||||
|
||||
|
||||
|
||||
function urlSafeBtoa(str: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
const uint8Array = encoder.encode(str);
|
||||
const binaryString = Array.prototype.map
|
||||
.call(uint8Array, (byte: number) => String.fromCharCode(byte))
|
||||
.join("");
|
||||
return btoa(binaryString).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
const linkToA = (url: string) => { window.open(url); };
|
||||
|
||||
const queryTopicTargetHumanList = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryTopicTargetHumanList`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.name || "";
|
||||
item.content = item.abstractz || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/human/basic?mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const organizationSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunity`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.name || "";
|
||||
item.content = item.abstractz || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/organization/basicAttr?mid=${item.mongoId}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const queryCommunityList = async () => {
|
||||
setIsSearching(true);
|
||||
const params = { keyword: searchText, page: 1, size: 50 };
|
||||
const queryString = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunityList?${queryString}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.community_name || "";
|
||||
item.content = item.community_description || "";
|
||||
const redirect = urlSafeBtoa(`/gene/#/commune/basicCommune?cid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const issueSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/issue/list?keyword=${searchText}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
item.title = item.issue_name || "";
|
||||
item.content = item.issue_type || "";
|
||||
const redirect = encodeURIComponent(`/gene/#/issue/zzyyIssue?mid=${item.id}&event_type=issue`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const eventSearch = async () => {
|
||||
setIsSearching(true);
|
||||
const response = await authenticatedFetch(`${consumerUrl}/event/queryEvents`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
body: JSON.stringify({ word: searchText, page: 1, size: 50, type: "hot", timeRange: "" }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
setIsSearching(false);
|
||||
const result = data.data;
|
||||
result.forEach((item) => {
|
||||
item.checked = false;
|
||||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
setListData(result);
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const topicQuery = async () => {
|
||||
setIsSearching(true);
|
||||
let result: listDataItem[] = [];
|
||||
for (const discriminant of ["威胁", "ZJ机"]) {
|
||||
const queryString = `keyword=${searchText}&discriminant=${discriminant}`;
|
||||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryEventFromA?${queryString}`, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { data: listDataItem[] };
|
||||
const results = data.data;
|
||||
results.forEach((item) => {
|
||||
item.checked = false;
|
||||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||||
});
|
||||
result = [...result, ...results];
|
||||
}
|
||||
}
|
||||
setIsSearching(false);
|
||||
setListData(result);
|
||||
};
|
||||
|
||||
const searchAChange = async (type: string) => {
|
||||
if (type === "human") await queryTopicTargetHumanList();
|
||||
else if (type === "organization") await organizationSearch();
|
||||
else if (type === "community") await queryCommunityList();
|
||||
else if (type === "issue") await issueSearch();
|
||||
else if (type === "event") await eventSearch();
|
||||
else if (type === "threat") await topicQuery();
|
||||
};
|
||||
|
||||
const searchTypeChange = (value: string) => {
|
||||
setSearchType(value);
|
||||
setListData([]);
|
||||
void searchAChange(value);
|
||||
};
|
||||
|
||||
const searchDataChange = () => {
|
||||
setIsSearching(true);
|
||||
setSearchValue(searchText);
|
||||
setTimeout(() => setIsSearching(false), 500);
|
||||
if (activeTab === "A") void searchAChange(searchType);
|
||||
};
|
||||
|
||||
const yUserId = getYUserId();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">添加来源</DialogTitle>
|
||||
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
|
||||
<Textarea
|
||||
placeholder="搜索资料..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
searchDataChange();
|
||||
}
|
||||
}}
|
||||
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
|
||||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={searchDataChange}
|
||||
disabled={isSearching || !searchText.trim()}
|
||||
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 标签页 */}
|
||||
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
|
||||
<div className="flex flex-nowrap overflow-x-auto">
|
||||
{(
|
||||
[
|
||||
{ key: "search" as const, label: "B系统(知识库)" },
|
||||
{ key: "A" as const, label: "A系统(基因库)" },
|
||||
{ key: "Y" as const, label: "Y系统(信息库)" },
|
||||
{ key: "actor" as const, label: "B系统(XWT检索)" },
|
||||
{ key: "upload" as const, label: "上传" },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => void searchActiveChange(key)}
|
||||
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{activeTab === key && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-5">
|
||||
{/* B系统知识库 */}
|
||||
{activeTab === "search" && !isSearching && (
|
||||
<iframe
|
||||
className="w-full h-[calc(100vh-200px)]"
|
||||
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y系统信息库 */}
|
||||
{activeTab === "Y" && !isSearching && (
|
||||
<iframe
|
||||
className="w-full h-[calc(100vh-200px)]"
|
||||
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A系统基因库 */}
|
||||
{activeTab === "A" && (
|
||||
<div className="bg-white rounded-lg">
|
||||
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||||
{searchTypes.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`}
|
||||
onClick={() => searchTypeChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除选中
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={isImporting}
|
||||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||||
>
|
||||
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||
导入选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listData.length === 0 && <Empty />}
|
||||
|
||||
{searchType === "human" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
{/* 上传文件显示 loading 状态 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{/* 上传文件完成状态标记 */}
|
||||
{item.is_uploaded_file && item.processing_status === 'error' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-red-500">处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
{item.is_uploaded_file && item.processing_status === 'success' && (
|
||||
<div className="absolute top-1 left-1">
|
||||
<span className="text-xs text-green-500">✓ 已完成</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
|
||||
<p className="truncate text-[#909090]">属地:{item.country}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "organization" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="w-[50px] mr-[10px]">
|
||||
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p>
|
||||
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
|
||||
<p className="truncate text-[#909090]">简介:{item.text}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "community" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div>
|
||||
<div className="flex items-center text-[#909090] mb-[5px]">
|
||||
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>}
|
||||
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>}
|
||||
</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span>
|
||||
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span>
|
||||
</div>
|
||||
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "issue" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div>
|
||||
<div className="truncate mb-[5px]">{item.title}</div>
|
||||
<div className="flex justify-between mb-[5px]">
|
||||
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span>
|
||||
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "event" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchType === "threat" && (
|
||||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||
{listData.map((item, index) => (
|
||||
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]">
|
||||
<div className="flex-1 min-w-0 pr-[25px]">
|
||||
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p>
|
||||
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p>
|
||||
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p>
|
||||
</div>
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* B系统XWT检索 */}
|
||||
{activeTab === "actor" && !isSearching && (
|
||||
<NetworkActor
|
||||
inputValue={searchText}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 上传 */}
|
||||
{activeTab === "upload" && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={UPLOAD_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length) void handleFileUpload(files);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-6"
|
||||
>
|
||||
{uploading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />上传中...</>
|
||||
) : (
|
||||
<><Plus className="h-4 w-4 mr-2" />选择文件上传</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">支持 PDF、Word、Excel、PPT、HTML、CSV、Markdown、TXT 及常见图片格式</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Empty = ({
|
||||
description = '暂无数据',
|
||||
size = 'default'
|
||||
}: {
|
||||
description?: string;
|
||||
size?: 'default' | 'small';
|
||||
}) => {
|
||||
return (
|
||||
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
|
||||
<div className="empty-image">
|
||||
<img src="/icon/empty-list.png" alt="暂无数据" />
|
||||
</div>
|
||||
{description && <p className="empty-description">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||
const token = getStoredAuth()?.access_token;
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface NetworkActorProps {
|
||||
inputValue: string;
|
||||
searchSpaceId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface platformItem {
|
||||
title: string;
|
||||
icon: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface languageItem {
|
||||
title: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface PoliticsDirItem {
|
||||
地区: string;
|
||||
语言: string;
|
||||
年龄段: string;
|
||||
性别: string;
|
||||
教育程度: string;
|
||||
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
|
||||
类型: string;
|
||||
对HZZLC: string;
|
||||
对MZZLC: string;
|
||||
对TZZLC: string[];
|
||||
账号描述: string;
|
||||
发文频率: string;
|
||||
活跃时间: string;
|
||||
}
|
||||
|
||||
interface 用户画像 {
|
||||
地区: string;
|
||||
教育程度: string;
|
||||
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
|
||||
语言: string;
|
||||
年龄段: string;
|
||||
账号描述: string;
|
||||
对TZZLC: string[];
|
||||
对HZZLC: string;
|
||||
对MZZLC: string[];
|
||||
性别: string;
|
||||
类型: string;
|
||||
发文频率: string;
|
||||
活跃时间: string;
|
||||
}
|
||||
|
||||
interface hx_info {
|
||||
语言: string;
|
||||
用户画像: 用户画像;
|
||||
}
|
||||
|
||||
interface UserDataItem {
|
||||
comment_count: number;
|
||||
country: string | null;
|
||||
agent_task_id: string | null;
|
||||
education: string | null;
|
||||
groupId: string | null;
|
||||
forward_count: number;
|
||||
political_stand: string;
|
||||
language: string;
|
||||
type: string;
|
||||
concern: string | null;
|
||||
profile_image: string | null;
|
||||
concern_topic: string | null;
|
||||
screen_name: string;
|
||||
school: string | null;
|
||||
is_alive: number;
|
||||
createdTime: string;
|
||||
data_provider: string | null;
|
||||
id: string;
|
||||
text: string | null;
|
||||
group: string | null;
|
||||
read_count: number;
|
||||
group_name: string;
|
||||
area: string | null;
|
||||
image: string | null;
|
||||
updatedTime: string;
|
||||
like_count: number;
|
||||
m_politics_dir: string;
|
||||
camp: string | null;
|
||||
tags: string[];
|
||||
army: string | null;
|
||||
is_sync_a: number;
|
||||
tweets_count: number;
|
||||
groupName: string | null;
|
||||
history_key: string;
|
||||
user_id: string;
|
||||
real_id: string | null;
|
||||
h_politics_dir: string | null;
|
||||
domain: string | null;
|
||||
name: string | null;
|
||||
position: string | null;
|
||||
job: string | null;
|
||||
age_round: string | null;
|
||||
birthday: string | null;
|
||||
is_virtual: number;
|
||||
isMedia: number;
|
||||
hx_info: hx_info;
|
||||
gender: string | null;
|
||||
agentInfo: PoliticsDirItem;
|
||||
profile_url: string | null;
|
||||
tagId: string[];
|
||||
user_name: string;
|
||||
a_update_time: string | null;
|
||||
values: string | null;
|
||||
follows_count: number;
|
||||
title: string | null;
|
||||
is_protected: number;
|
||||
platform: string;
|
||||
importType: string;
|
||||
update_time: string;
|
||||
key: string;
|
||||
introduction: string | null;
|
||||
brief: string | null;
|
||||
creator: string;
|
||||
history_title: string;
|
||||
tweets_ratio: string | null;
|
||||
is_verified: number;
|
||||
t_politics_dir: string[];
|
||||
eventMap: string | null;
|
||||
fans_count: number;
|
||||
a_create_time: string | null;
|
||||
hobbies: string[];
|
||||
location: string | null;
|
||||
average_count: number;
|
||||
ratio: string | null;
|
||||
classification?: string | null;
|
||||
groupDiscussTags?: string[];
|
||||
checked?: boolean;
|
||||
content?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
|
||||
const [platforms, setPlatforms] = useState<platformItem[]>([
|
||||
{ title: 'X', icon: 'x.png', checked: false },
|
||||
{ title: 'Facebook', icon: 'facebook.png', checked: false },
|
||||
{ title: 'Reddit', icon: 'reddit.png', checked: false },
|
||||
{ title: 'PTT', icon: 'ptt.png', checked: false },
|
||||
{ title: 'YouTube', icon: 'youtube.png', checked: false },
|
||||
{ title: 'Dcard', icon: 'dcard.png', checked: false },
|
||||
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
|
||||
{ title: 'Threads', icon: 'threads.png', checked: false },
|
||||
{ title: 'Instagram', icon: 'ins.png', checked: false },
|
||||
]);
|
||||
const platformList = useRef<string[]>([]);
|
||||
const [languages, setLanguages] = useState<languageItem[]>([
|
||||
{ title: '繁体中文', checked: false },
|
||||
{ title: '简体中文', checked: false },
|
||||
{ title: '英文', checked: false },
|
||||
{ title: '日文', checked: false },
|
||||
{ title: '其他', checked: false },
|
||||
]);
|
||||
const languageList = useRef<string[]>([]);
|
||||
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
|
||||
const [allLanguage, setAllLanguage] = useState<boolean>(true);
|
||||
const [sortType, setSortType] = useState<string>('fans_count');
|
||||
const sortTypeValue = useRef('fans_count');
|
||||
const [fansSort, setFansSort] = useState<string>('desc');
|
||||
const sortValue = useRef('desc');
|
||||
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
|
||||
const [userList, setUserList] = useState<UserDataItem[]>([]);
|
||||
const [searchText, setSearchText] = useState<string>(inputValue);
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||
const [searchType, setSearchType] = useState<string>('network');
|
||||
const searchValue = useRef<string>('network');
|
||||
const [selNetCount, setSelNetCount] = useState<number>(0);
|
||||
const [actorCount, setActorCount] = useState<number>(0);
|
||||
const [directionType, setDirectionType] = useState<string>('TW');
|
||||
const directionValue = useRef<string>('TW');
|
||||
const consumerUrl = 'http://ch1.b.uat.4.cn/consumer';
|
||||
const backendUrl = typeof window !== 'undefined'
|
||||
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
|
||||
: 'http://localhost:8001';
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const searchChange = async () => {
|
||||
setIsSearching(true);
|
||||
if (searchValue.current === 'network') {
|
||||
await agentUserSearch();
|
||||
} else {
|
||||
await agentGroupSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const setDirectionChange = async (type: string) => {
|
||||
setDirectionType(type);
|
||||
directionValue.current = type;
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const searchTypeChange = async (value: string) => {
|
||||
setSearchType(value);
|
||||
searchValue.current = value;
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const allPlatformChange = async () => {
|
||||
setAllPlatforms(true);
|
||||
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
platformList.current = [];
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleTogglePlatform = async (index: number) => {
|
||||
setPlatforms((prev) => {
|
||||
const newPlatforms = prev.map((item, i) =>
|
||||
i === index ? { ...item, checked: !item.checked } : item
|
||||
);
|
||||
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
|
||||
platformList.current = checkedTitles;
|
||||
setAllPlatforms(newPlatforms.every((item) => !item.checked));
|
||||
return newPlatforms;
|
||||
});
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const allLanguageChange = async () => {
|
||||
setAllLanguage(true);
|
||||
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
languageList.current = [];
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const handleToggleLanguage = async (index: number) => {
|
||||
setLanguages((prev) => {
|
||||
const newItems = prev.map((item, i) =>
|
||||
i === index ? { ...item, checked: !item.checked } : item
|
||||
);
|
||||
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
|
||||
languageList.current = checkedTitles;
|
||||
setAllLanguage(newItems.every((item) => !item.checked));
|
||||
return newItems;
|
||||
});
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const sortTypeChange = async (type: string) => {
|
||||
setSortType(type);
|
||||
sortTypeValue.current = type;
|
||||
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
|
||||
if (type === 'fans_count') setFansSort(sortValue.current);
|
||||
else if (type === '_score') setRelevanceSort(sortValue.current);
|
||||
await searchChange();
|
||||
};
|
||||
|
||||
const agentUserSearch = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||
body: JSON.stringify({
|
||||
historyKey: [],
|
||||
historyTitle: '',
|
||||
keyword: searchText,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsSearching(false);
|
||||
const result: UserDataItem[] = data.data.list;
|
||||
result.forEach((item) => {
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
|
||||
item.title = item.user_name || item.screen_name || item.user_id;
|
||||
item.content = item.agentInfo['账号描述'];
|
||||
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
|
||||
});
|
||||
setUserList(result);
|
||||
setSelNetCount(data.data.total);
|
||||
setActorCount(data.data.zs_total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const agentGroupSearch = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||
body: JSON.stringify({
|
||||
historyKey: [],
|
||||
historyTitle: '',
|
||||
keyword: searchText,
|
||||
language: languageList.current,
|
||||
page: 1,
|
||||
limit: 12,
|
||||
platform: platformList.current,
|
||||
politicalStand: directionValue.current,
|
||||
sort: sortTypeValue.current,
|
||||
asc: sortValue.current,
|
||||
typeKeyword: 9,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setIsSearching(false);
|
||||
const result: UserDataItem[] = data.data.data;
|
||||
result.forEach((item) => {
|
||||
item.hobbies = item.groupDiscussTags || [];
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.classification = `${item.political_stand}`;
|
||||
item.title = item.group_name || item.groupName;
|
||||
if (item.hx_info?.['用户画像']) {
|
||||
item.content = item.hx_info['用户画像']['账号描述'];
|
||||
}
|
||||
item.url = '';
|
||||
});
|
||||
setUserList(result);
|
||||
setActorCount(data.data.total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||||
setUserList((prevSources) => {
|
||||
const newSources = prevSources.map((item, aIndex) =>
|
||||
aIndex === index ? { ...item, checked } : item
|
||||
);
|
||||
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||||
return newSources;
|
||||
});
|
||||
};
|
||||
|
||||
const linkToB2 = (data: UserDataItem) => {
|
||||
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||
setSelectedSearchSources([]);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSearchSources.length === 0) {
|
||||
toast.warning('没有可导入的来源');
|
||||
return;
|
||||
}
|
||||
setIsImporting(true);
|
||||
const dataList = selectedSearchSources.map((item) => ({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
url: item.url,
|
||||
}));
|
||||
try {
|
||||
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
source_list: dataList,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||
setSearchText('');
|
||||
setUserList([]);
|
||||
handleBatchDelete();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.detail || '导入失败,请重试');
|
||||
}
|
||||
} catch {
|
||||
toast.error('导入失败,请重试');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void searchChange();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-[5px] p-[10px]">
|
||||
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">所属平台:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
|
||||
onClick={() => void allPlatformChange()}
|
||||
>全部</span>
|
||||
{platforms.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleTogglePlatform(index)}
|
||||
>
|
||||
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
|
||||
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
|
||||
<span className="ml-[5px]">{item.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">常用语言:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
|
||||
onClick={() => void allLanguageChange()}
|
||||
>全部</span>
|
||||
{languages.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||
onClick={() => void handleToggleLanguage(index)}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">方向:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[10px] flex">
|
||||
<span className="text-[#3D3D3D]">类型:</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-center gap-[10px] flex-wrap">
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
|
||||
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
|
||||
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
|
||||
<div className="flex items-center ml-[10px]">
|
||||
<div className="title-bar mr-[8px]"></div>
|
||||
<div className="text-[#454859]">用户表格管理</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||
onClick={() => void sortTypeChange('fans_count')}
|
||||
>
|
||||
<span className="leading-[16px]">粉丝数排序</span>
|
||||
<div className="ml-[10px]">
|
||||
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||
onClick={() => void sortTypeChange('_score')}
|
||||
>
|
||||
<span className="leading-[16px]">相关度排序</span>
|
||||
<div className="ml-[10px]">
|
||||
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
{searchType === 'network' && (
|
||||
<>
|
||||
<div className="flex items-center ml-[35px]">
|
||||
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
|
||||
<div className="ml-[5px]">
|
||||
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
|
||||
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
|
||||
</>
|
||||
)}
|
||||
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
|
||||
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
|
||||
<div className="ml-[5px]">
|
||||
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
|
||||
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSearchSources.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
删除选中
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={isImporting}
|
||||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||||
>
|
||||
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
|
||||
导入选中
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-[10px]">
|
||||
<div className="grid grid-cols-4 gap-[10px] user-list">
|
||||
{userList.map((item, index) => (
|
||||
<div key={index} className="user-item p-[10px] relative">
|
||||
<div className="absolute top-[5px] right-[10px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked ?? false}
|
||||
onChange={() => listDataCheckedChange(index, !item.checked)}
|
||||
className="w-4 h-4 cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
|
||||
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
|
||||
<div className="flex-1 min-w-0 pl-[10px]">
|
||||
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
|
||||
<div className="flex justify-between mb-[10px]">
|
||||
<div className="flex items-center">
|
||||
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
|
||||
<span>粉丝数:{item.fans_count || '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
|
||||
<span>{item.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>实体人:{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
|
||||
<div className="flex items-center mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>分类:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>标签:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-[5px]">
|
||||
{(item.hobbies ?? []).map((hobbie) => (
|
||||
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline mb-[10px]">
|
||||
<div className="w-[70px] flex items-center">
|
||||
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||
<span>简介:</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="line-clamp-3 text-ellipsis overflow-hidden">
|
||||
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -358,6 +358,20 @@ function MessageImage({
|
||||
);
|
||||
}
|
||||
|
||||
// While a notebook citation marker is still streaming in (e.g. "[[cite:64" or
|
||||
// "[[cite:101]"), Streamdown's parseIncompleteMarkdown treats the trailing
|
||||
// unclosed "[" as an incomplete link and rewrites it to
|
||||
// "[[cite:64](streamdown:incomplete-link)", which the rehypeCitations regex can
|
||||
// no longer match — so the citation flickers as a broken link mid-stream. We
|
||||
// strip the half-arrived marker so it stays hidden until the closing "]]"
|
||||
// arrives, at which point rehypeCitations renders it. A complete
|
||||
// "[[cite:101]]" has no trailing incomplete marker, so this is a no-op.
|
||||
const INCOMPLETE_CITE_TAIL = /\[\[(?:c(?:i(?:t(?:e(?::\d*\]?)?)?)?)?)?$/;
|
||||
|
||||
function stripIncompleteCitationMarker(content: string): string {
|
||||
return content.replace(INCOMPLETE_CITE_TAIL, '');
|
||||
}
|
||||
|
||||
function MessageContent_({
|
||||
className,
|
||||
message,
|
||||
@@ -460,7 +474,8 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
return applyInlineCitations(rawContent ?? '', displaySources);
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -273,9 +273,24 @@ function StatRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
|
||||
interface CuratorDraft {
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_VALUE = "__default__";
|
||||
|
||||
function toDraft(config: CuratorConfig): CuratorDraft {
|
||||
return {
|
||||
interval_hours: config.curator.interval_hours,
|
||||
stale_after_days: config.curator.stale_after_days,
|
||||
archive_after_days: config.curator.archive_after_days,
|
||||
model_name: config.curator.model_name,
|
||||
};
|
||||
}
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
@@ -283,10 +298,10 @@ function ConfigCard() {
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
const { models } = useModels();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
const [draft, setDraft] = useState<CuratorDraft | null>(null);
|
||||
useEffect(() => {
|
||||
if (config && !draft) {
|
||||
setDraft(config);
|
||||
setDraft(toDraft(config));
|
||||
}
|
||||
}, [config, draft]);
|
||||
|
||||
@@ -305,14 +320,12 @@ function ConfigCard() {
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
!!config &&
|
||||
JSON.stringify(draft) !== JSON.stringify(config);
|
||||
const original = config ? toDraft(config) : null;
|
||||
const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updated = await updateConfig.mutateAsync(draft);
|
||||
setDraft(updated);
|
||||
await updateConfig.mutateAsync(draft);
|
||||
toast.success(tt.saveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -326,29 +339,6 @@ function ConfigCard() {
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<FieldRow
|
||||
label={tt.enabledLabel}
|
||||
helper={tt.enabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.curatorEnabledLabel}
|
||||
helper={tt.curatorEnabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.curator.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: { ...draft.curator, enabled: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.intervalHoursLabel}
|
||||
helper={tt.intervalHoursHelper}
|
||||
@@ -357,15 +347,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.interval_hours}
|
||||
value={draft.interval_hours}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
interval_hours: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, interval_hours: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -377,15 +361,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.stale_after_days}
|
||||
value={draft.stale_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
stale_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, stale_after_days: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -397,15 +375,9 @@ function ConfigCard() {
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.archive_after_days}
|
||||
value={draft.archive_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
archive_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) })
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -414,15 +386,9 @@ function ConfigCard() {
|
||||
helper={tt.curatorModelHelper}
|
||||
>
|
||||
<Select
|
||||
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
value={draft.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
|
||||
},
|
||||
})
|
||||
setDraft({ ...draft, model_name: v === DEFAULT_MODEL_VALUE ? null : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -451,7 +417,7 @@ function ConfigCard() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={() => config && setDraft(config)}
|
||||
onClick={() => original && setDraft(original)}
|
||||
>
|
||||
{tt.reset}
|
||||
</Button>
|
||||
|
||||
@@ -19,9 +19,6 @@ import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSq
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { env } from "@/env";
|
||||
|
||||
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
const NAV_ITEM_CLASS =
|
||||
@@ -180,7 +177,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
|
||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,8 @@ export function WorkspaceNavStudio() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isError || (!isLoading && notebooks.length === 0)) {
|
||||
// 加载中、token 失败、空列表都不渲染整段,避免在 token 未确认前先闪一段工作室 UI 再消失。
|
||||
if (isLoading || isError || notebooks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,44 +91,38 @@ export function WorkspaceNavStudio() {
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenu className="mt-0.5 gap-0.5">
|
||||
{/* 新建笔记本 */}
|
||||
{/* 新建空间 */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
|
||||
>
|
||||
<Plus className="shrink-0" />
|
||||
<span>新建笔记本</span>
|
||||
<span>新建空间</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{isLoading
|
||||
? Array.from({ length: 3 }).map((_, i) => (
|
||||
<SidebarMenuItem key={i}>
|
||||
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: preview.map((nb) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
title={nb.description || nb.name}
|
||||
>
|
||||
<BookOpen className="shrink-0 size-4" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{/* 空间列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
|
||||
{preview.map((nb) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link
|
||||
to={`/page/workspace/studio/notebooks/${nb.id}`}
|
||||
title={nb.description || nb.name}
|
||||
>
|
||||
<BookOpen className="shrink-0 size-4" />
|
||||
<span className="truncate">{nb.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
{/* 所有笔记本 */}
|
||||
{!isLoading && notebooks.length > 0 && (
|
||||
{/* 所有空间 */}
|
||||
{notebooks.length > 0 && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
@@ -135,8 +130,8 @@ export function WorkspaceNavStudio() {
|
||||
className={cn(NAV_ITEM_CLASS)}
|
||||
>
|
||||
<Link to="/page/workspace/studio/notebooks">
|
||||
<span className="mr-0.5 text-base leading-none">···</span>
|
||||
<span>所有笔记本</span>
|
||||
{/* <span className="mr-0.5 text-base leading-none">···</span> */}
|
||||
<span>所有空间</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -149,7 +144,7 @@ export function WorkspaceNavStudio() {
|
||||
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
@@ -157,7 +152,7 @@ export function WorkspaceNavStudio() {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 笔记本外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
/** 空间外部系统后端 API 地址,通过 VITE_AIPI_BACKENP_URL 配置。 */
|
||||
export function getNotebookApiBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
|
||||
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
|
||||
|
||||
@@ -17,8 +17,15 @@ export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export interface CuratorConfigUpdatePayload {
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
model_name: string | null;
|
||||
}
|
||||
|
||||
export async function updateCuratorConfig(
|
||||
payload: CuratorConfig,
|
||||
payload: CuratorConfigUpdatePayload,
|
||||
): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runCurator,
|
||||
updateCuratorConfig,
|
||||
} from "./api";
|
||||
import type { CuratorConfigUpdatePayload } from "./api";
|
||||
import type { CuratorConfig } from "./types";
|
||||
|
||||
// All curator-related queries are per-user data (status / config / logs all
|
||||
@@ -51,7 +52,7 @@ export function useUpdateCuratorConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||
mutationFn: (payload: CuratorConfigUpdatePayload) => updateCuratorConfig(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.config });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
|
||||
@@ -20,14 +20,16 @@ export async function fetchStudioToken(email: string): Promise<string> {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
|
||||
export async function fetchStudioNotebooks(
|
||||
token: string,
|
||||
): Promise<StudioNotebook[]> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
|
||||
if (!res.ok) throw new Error(`获取空间列表失败:${res.status}`);
|
||||
return res.json() as Promise<StudioNotebook[]>;
|
||||
}
|
||||
|
||||
@@ -62,13 +64,23 @@ export async function uploadSourceText(
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/documents/textupload`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ search_space_id: notebookId, content, title: title || "粘贴的文字" }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search_space_id: notebookId,
|
||||
content,
|
||||
title: title || "粘贴的文字",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`上传失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function deleteSearchSpace(id: number, token: string): Promise<void> {
|
||||
export async function deleteSearchSpace(
|
||||
id: number,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
@@ -78,12 +90,19 @@ export async function deleteSearchSpace(id: number, token: string): Promise<void
|
||||
if (!res.ok) throw new Error(`删除失败:${res.status}`);
|
||||
}
|
||||
|
||||
export async function renameSearchSpace(id: number, name: string, token: string): Promise<void> {
|
||||
export async function renameSearchSpace(
|
||||
id: number,
|
||||
name: string,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const apiBase = base();
|
||||
if (!apiBase) throw new Error("VITE_AIPI_FRONTEND_URL 未配置");
|
||||
const res = await fetch(`${apiBase}/api/v1/searchspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`重命名失败:${res.status}`);
|
||||
|
||||
@@ -4,11 +4,15 @@ import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
|
||||
import {
|
||||
fetchNotebookSources,
|
||||
fetchStudioNotebooks,
|
||||
fetchStudioToken,
|
||||
} from "./api";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import type { SourceItem, StudioNotebook } from "./types";
|
||||
|
||||
export function useStudioNotebooks(): {
|
||||
export function useStudioNotebooks(enabled = true): {
|
||||
notebooks: StudioNotebook[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
@@ -19,17 +23,22 @@ export function useStudioNotebooks(): {
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: enabled && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
// 账号不存在(404/422)是永久错误,重试无意义,立即让 isError 落地
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const notebooksQuery = useQuery({
|
||||
queryKey: ["studio", "notebooks", tokenQuery.data],
|
||||
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
|
||||
enabled: Boolean(tokenQuery.data),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: enabled && Boolean(tokenQuery.data),
|
||||
// 空间列表不会高频变动;新建/删除路径已通过 invalidateQueries 主动失效
|
||||
// (见 workspace-nav-studio.tsx),不需要短 staleTime + windowFocus 双兜底。
|
||||
staleTime: 10 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -71,7 +80,8 @@ export function useNotebookChat(
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
@@ -99,7 +109,9 @@ export function useNotebookChat(
|
||||
const sourceIds =
|
||||
selectedSourceIds.length > 0
|
||||
? toDocIds(selectedSourceIds)
|
||||
: sources.map((s) => s.document?.id).filter((id): id is number => id !== undefined);
|
||||
: sources
|
||||
.map((s) => s.document?.id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
|
||||
await thread.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
@@ -118,36 +130,52 @@ export function useNotebookChat(
|
||||
);
|
||||
};
|
||||
|
||||
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
|
||||
return {
|
||||
thread,
|
||||
messages: thread.messages,
|
||||
isStreaming: thread.isLoading,
|
||||
send,
|
||||
threadId,
|
||||
setThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useNotebookSources(notebookId: number | null): {
|
||||
export function useNotebookSources(
|
||||
notebookId: number | null,
|
||||
overrideToken?: string,
|
||||
): {
|
||||
sources: SourceItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
} {
|
||||
const email = getStoredAuth()?.email ?? "";
|
||||
|
||||
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
|
||||
// 若外部直接提供 user_token(如 notebook-token 接口返回的),跳过 getTokenByEmail
|
||||
const tokenQuery = useQuery({
|
||||
queryKey: ["studio", "token", email],
|
||||
queryFn: () => fetchStudioToken(email),
|
||||
enabled: Boolean(email),
|
||||
enabled: !overrideToken && Boolean(email),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const resolvedToken = overrideToken ?? tokenQuery.data;
|
||||
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ["studio", "sources", notebookId],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
|
||||
enabled: notebookId !== null && Boolean(tokenQuery.data),
|
||||
staleTime: 60 * 1000,
|
||||
queryKey: ["studio", "sources", notebookId, overrideToken ?? "default"],
|
||||
queryFn: () => fetchNotebookSources(notebookId!, resolvedToken!),
|
||||
enabled: notebookId !== null && Boolean(resolvedToken),
|
||||
// 笔记文档列表低频变动;增删时调用方应主动 invalidateQueries(["studio", "sources"]) 失效。
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sources: sourcesQuery.data ?? [],
|
||||
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
|
||||
isError: tokenQuery.isError || sourcesQuery.isError,
|
||||
isLoading:
|
||||
(!overrideToken && tokenQuery.isLoading) || sourcesQuery.isLoading,
|
||||
isError: (!overrideToken && tokenQuery.isError) || sourcesQuery.isError,
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,7 @@ function readEnv(
|
||||
| "VITE_AIPI_BACKENP_URL"
|
||||
| "VITE_AIPI_FRONTEND_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN"
|
||||
| "VITE_STUDIO_ENABLED",
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
const value = import.meta.env[key];
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
@@ -26,5 +25,4 @@ export const env = {
|
||||
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_AIPI_BACKENP_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
STUDIO_ENABLED: readEnv("VITE_STUDIO_ENABLED"),
|
||||
};
|
||||
@@ -22,6 +22,12 @@ interface AIWritingFormProps {
|
||||
disabled?: boolean;
|
||||
/** workspace 入口:隐藏严格模式、目标读者 */
|
||||
hideAdvancedFields?: boolean;
|
||||
/** 预选空间 ID(传入时自动切换素材来源为「我的空间」) */
|
||||
initialNotebookId?: number;
|
||||
/** 预选文档 ID 列表 */
|
||||
initialDocumentIds?: number[];
|
||||
/** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
@@ -39,7 +45,7 @@ const MAX_INTENT_LENGTH = 500;
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' },
|
||||
{ value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' },
|
||||
{ value: 'notebook', label: '我的笔记', desc: '从笔记本中检索素材' },
|
||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
@@ -50,6 +56,9 @@ export function AIWritingForm({
|
||||
onSubmit,
|
||||
disabled,
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
intent, setIntent,
|
||||
@@ -62,13 +71,15 @@ export function AIWritingForm({
|
||||
materialSource, setMaterialSource,
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id(master 引入,useWritingForm 未含)
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
|
||||
initialNotebookId ?? null,
|
||||
);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
@@ -86,6 +97,7 @@ export function AIWritingForm({
|
||||
[models, modelName],
|
||||
);
|
||||
|
||||
|
||||
// 初始化时加载文章类型
|
||||
useEffect(() => {
|
||||
refreshTypes();
|
||||
@@ -127,7 +139,8 @@ export function AIWritingForm({
|
||||
|
||||
function handleMaterialSourceChange(val: MaterialSource) {
|
||||
setMaterialSource(val);
|
||||
if (val !== 'notebook') {
|
||||
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
|
||||
if (val !== 'notebook' && !studioToken) {
|
||||
setSelectedNotebookId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
@@ -153,9 +166,9 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
materialSource,
|
||||
materialSource: materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
// 只有选了"我的笔记"才传笔记参数
|
||||
// 只有选了"我的空间"才传笔记参数
|
||||
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
});
|
||||
@@ -283,32 +296,35 @@ export function AIWritingForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 选了"我的笔记"才展示笔记本 / 文档选择器 */}
|
||||
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
|
||||
{materialSource === 'notebook' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* studioToken 模式:空间已锁定,不展示选择器 */}
|
||||
{!studioToken && (
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{selectedNotebookId !== null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourcesLoading
|
||||
? '文档加载中…'
|
||||
: `选择文档(可多选;不选则搜整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
</p>
|
||||
{!sourcesLoading && (
|
||||
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useAIWriting } from '../../contexts/AIWritingContext';
|
||||
import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||||
import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions';
|
||||
|
||||
/**
|
||||
* 暂停 / 继续功能开关(默认 false = 关)。
|
||||
@@ -70,10 +70,17 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
|
||||
export function AIWritingPanel({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
@@ -213,6 +220,18 @@ export function AIWritingPanel({
|
||||
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||||
}, []);
|
||||
|
||||
// 路径3:URL ?session=xxx 直接定位到指定会话
|
||||
useEffect(() => {
|
||||
const sessionId = searchParams.get('session');
|
||||
if (!sessionId) return;
|
||||
// 消费参数,避免刷新重复触发
|
||||
setSearchParams((prev) => { prev.delete('session'); return prev; }, { replace: true });
|
||||
getAIWritingSession(sessionId)
|
||||
.then((session) => handleSelectHistoryRef.current(session))
|
||||
.catch(() => { /* session 不存在时静默忽略 */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅挂载时执行一次
|
||||
|
||||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||||
@@ -339,6 +358,9 @@ export function AIWritingPanel({
|
||||
<AIWritingForm
|
||||
onSubmit={startWriting}
|
||||
hideAdvancedFields={hideAdvancedFields}
|
||||
initialNotebookId={initialNotebookId}
|
||||
initialDocumentIds={initialDocumentIds}
|
||||
studioToken={studioToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
@@ -256,6 +256,10 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
const stream = streamRef.current;
|
||||
if (!stream) return;
|
||||
try {
|
||||
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
|
||||
// 三个合法值:knowledge_base / skill / notebook,后端 state.py 接受任意 str。
|
||||
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
|
||||
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
@@ -266,7 +270,9 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
material_source: request.materialSource,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
@@ -368,8 +374,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}, [state.sessionId, state.currentPause, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
|
||||
state.status]);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
void streamRef.current?.stop();
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useWritingFormOptional(): WritingFormValue | null {
|
||||
return useContext(WritingFormContext);
|
||||
}
|
||||
|
||||
export function WritingFormProvider({ children }: { children: ReactNode }) {
|
||||
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [outlineText, setOutlineText] = useState('');
|
||||
@@ -61,7 +61,7 @@ export function WritingFormProvider({ children }: { children: ReactNode }) {
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>('knowledge_base');
|
||||
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill' | 'notebook'>(initialMaterialSource ?? 'knowledge_base');
|
||||
const [modelName, setModelName] = useState('');
|
||||
|
||||
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
|
||||
|
||||
@@ -115,11 +115,17 @@ const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
|
||||
/**
|
||||
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
|
||||
*/
|
||||
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
interface NotebookProps {
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
}
|
||||
|
||||
function WritingFormPanel({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
return (
|
||||
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +135,7 @@ function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean
|
||||
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
|
||||
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
|
||||
*/
|
||||
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
|
||||
function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const { state } = useAIWriting();
|
||||
const { isHistoryMode } = useAIWritingHistory();
|
||||
@@ -162,7 +168,7 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
@@ -202,8 +208,14 @@ function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }
|
||||
|
||||
export default function AIWritingPage({
|
||||
hideAdvancedFields = false,
|
||||
initialNotebookId,
|
||||
initialDocumentIds,
|
||||
studioToken,
|
||||
}: {
|
||||
hideAdvancedFields?: boolean;
|
||||
initialNotebookId?: number;
|
||||
initialDocumentIds?: number[];
|
||||
studioToken?: string;
|
||||
} = {}) {
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
@@ -211,11 +223,11 @@ export default function AIWritingPage({
|
||||
<CanvasUIProvider>
|
||||
<AIWritingHistoryProvider>
|
||||
<AIWritingProvider>
|
||||
<WritingFormProvider>
|
||||
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
|
||||
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||
sourceType: "internet" | "intranet" | "skill" | "notebook";
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
@@ -22,7 +22,13 @@ export interface AIMaterial {
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
type:
|
||||
| "list_skills"
|
||||
| "match_skills"
|
||||
| "select_skills"
|
||||
| "invoke_skill"
|
||||
| "skill_result"
|
||||
| "stop_early";
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
@@ -41,8 +47,8 @@ export interface SkillSearchStep {
|
||||
// guard 兼容,防止前端流式接收到旧大纲时崩溃。详见 normalizeKeyPoint() helper。
|
||||
|
||||
export interface KeyPoint {
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
pointTitle: string; // 小节标题(渲染为 ### 三级标题)
|
||||
subPoints: string[]; // 子要点(可空;非空时在小节正文起始处以 bullet 列表呈现)
|
||||
}
|
||||
|
||||
export interface OutlineSection {
|
||||
@@ -65,23 +71,28 @@ export interface AIOutline {
|
||||
* - 任何意外形态 → 空 KeyPoint(防止崩溃,不抛异常)
|
||||
*/
|
||||
export function normalizeKeyPoint(raw: unknown): KeyPoint {
|
||||
if (typeof raw === 'string') {
|
||||
if (typeof raw === "string") {
|
||||
return { pointTitle: raw, subPoints: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
if (raw && typeof raw === "object") {
|
||||
const r = raw as Record<string, unknown>;
|
||||
const title =
|
||||
typeof r.pointTitle === 'string' ? r.pointTitle :
|
||||
typeof r.point_title === 'string' ? r.point_title :
|
||||
typeof r.title === 'string' ? r.title :
|
||||
'';
|
||||
typeof r.pointTitle === "string"
|
||||
? r.pointTitle
|
||||
: typeof r.point_title === "string"
|
||||
? r.point_title
|
||||
: typeof r.title === "string"
|
||||
? r.title
|
||||
: "";
|
||||
const subsRaw = (r.subPoints ?? r.sub_points ?? []) as unknown;
|
||||
const subPoints: string[] = Array.isArray(subsRaw)
|
||||
? subsRaw.map((s) => (typeof s === 'string' ? s : String(s ?? ''))).filter(Boolean)
|
||||
? subsRaw
|
||||
.map((s) => (typeof s === "string" ? s : String(s ?? "")))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return { pointTitle: title, subPoints };
|
||||
}
|
||||
return { pointTitle: '', subPoints: [] };
|
||||
return { pointTitle: "", subPoints: [] };
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
@@ -93,7 +104,7 @@ export interface BlockedSection {
|
||||
}
|
||||
|
||||
/** 单个被阻塞章节的处理决策 */
|
||||
export type SectionDecisionKind = 'supplement' | 'delete' | 'loose';
|
||||
export type SectionDecisionKind = "supplement" | "delete" | "loose";
|
||||
|
||||
export interface SectionDecision {
|
||||
sectionTitle: string;
|
||||
@@ -103,15 +114,15 @@ export interface SectionDecision {
|
||||
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewIssue {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
severity: 'major' | 'minor';
|
||||
category: "fact" | "logic" | "language";
|
||||
severity: "major" | "minor";
|
||||
location: string;
|
||||
issue: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
verdict: 'pass' | 'reject';
|
||||
verdict: "pass" | "reject";
|
||||
factScore: number;
|
||||
logicScore: number;
|
||||
languageScore: number;
|
||||
@@ -123,44 +134,44 @@ export interface ReviewResult {
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| 'idle'
|
||||
| 'researching'
|
||||
| 'awaiting_material_confirm'
|
||||
| 'writing_outline'
|
||||
| 'awaiting_outline_confirm'
|
||||
| 'writing_draft'
|
||||
| 'awaiting_section_help'
|
||||
| 'awaiting_draft_confirm'
|
||||
| 'reviewing'
|
||||
| 'awaiting_review_confirm'
|
||||
| 'revising'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "idle"
|
||||
| "researching"
|
||||
| "awaiting_material_confirm"
|
||||
| "writing_outline"
|
||||
| "awaiting_outline_confirm"
|
||||
| "writing_draft"
|
||||
| "awaiting_section_help"
|
||||
| "awaiting_draft_confirm"
|
||||
| "reviewing"
|
||||
| "awaiting_review_confirm"
|
||||
| "revising"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProgressEventType =
|
||||
| 'connected'
|
||||
| 'step_start'
|
||||
| 'step_done'
|
||||
| 'intent_chunk'
|
||||
| 'intent_ready'
|
||||
| 'research_progress'
|
||||
| 'materials_ready'
|
||||
| 'outline_chunk'
|
||||
| 'outline_ready'
|
||||
| 'draft_chunk'
|
||||
| 'draft_ready'
|
||||
| 'review_chunk'
|
||||
| 'review_item_done'
|
||||
| 'review_ready'
|
||||
| 'await_user'
|
||||
| 'resumed'
|
||||
| 'notice'
|
||||
| 'skill_search_step'
|
||||
| 'skill_select_chunk'
|
||||
| 'done'
|
||||
| 'error';
|
||||
| "connected"
|
||||
| "step_start"
|
||||
| "step_done"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "research_progress"
|
||||
| "materials_ready"
|
||||
| "outline_chunk"
|
||||
| "outline_ready"
|
||||
| "draft_chunk"
|
||||
| "draft_ready"
|
||||
| "review_chunk"
|
||||
| "review_item_done"
|
||||
| "review_ready"
|
||||
| "await_user"
|
||||
| "resumed"
|
||||
| "notice"
|
||||
| "skill_search_step"
|
||||
| "skill_select_chunk"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface ProgressEvent {
|
||||
type: ProgressEventType;
|
||||
@@ -183,13 +194,18 @@ export interface ProgressEvent {
|
||||
draftTitle?: string;
|
||||
revisionCount?: number;
|
||||
// review_item_done (streaming per-category result)
|
||||
reviewItemCategory?: 'fact' | 'logic' | 'language';
|
||||
reviewItemCategory?: "fact" | "logic" | "language";
|
||||
reviewItemScore?: number;
|
||||
reviewItemIssues?: ReviewIssue[];
|
||||
// review_ready
|
||||
reviewResult?: ReviewResult;
|
||||
// await_user
|
||||
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint?:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
pauseContext?: Record<string, unknown>;
|
||||
// section_help:素材不足、待用户决策的章节
|
||||
blockedSections?: BlockedSection[];
|
||||
@@ -229,19 +245,19 @@ export interface AIWritingRequest {
|
||||
/** 素材检索关键词数量(1~10,默认 1) */
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的笔记 */
|
||||
materialSource: 'knowledge_base' | 'skill' | 'notebook';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
writingMode: "strict" | "loose";
|
||||
/** 素材来源:knowledge_base=知识库 / skill=技能检索 / notebook=我的空间 */
|
||||
materialSource: "knowledge_base" | "skill" | "notebook";
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
|
||||
noteSourceIds?: number[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamingReviewItem {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
category: "fact" | "logic" | "language";
|
||||
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
|
||||
isDone: boolean;
|
||||
// 流式过程文本(正文 / 思考分开)
|
||||
@@ -266,7 +282,7 @@ export interface RevisionSummary {
|
||||
export interface StreamingDraftSection {
|
||||
sectionIndex: number;
|
||||
sectionTitle: string;
|
||||
text: string; // 正文内容(非思考)
|
||||
text: string; // 正文内容(非思考)
|
||||
thinkingText: string; // 思考内容(<think>...</think> 内)
|
||||
isDone: boolean;
|
||||
}
|
||||
@@ -274,7 +290,12 @@ export interface StreamingDraftSection {
|
||||
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletedIntervention {
|
||||
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm' | 'section_help';
|
||||
pausePoint:
|
||||
| "material_confirm"
|
||||
| "outline_confirm"
|
||||
| "draft_confirm"
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
action: string;
|
||||
// material_confirm
|
||||
materials?: AIMaterial[];
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function LightAppNoteListPage() {
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
<span className="text-base font-medium">新建空间</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export default function LightAppNoteListPage() {
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-medium">新建笔记本</span>
|
||||
<span className="text-base font-medium">新建空间</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -230,7 +230,7 @@ export default function LightAppNoteListPage() {
|
||||
isDark ? "text-slate-100" : "text-slate-950",
|
||||
)}
|
||||
>
|
||||
我的笔记本
|
||||
我的空间
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
search_space_id: number;
|
||||
user_id: string;
|
||||
user_token: string;
|
||||
}
|
||||
|
||||
export default function NotebookAIWritingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [data, setData] = useState<NotebookTokenData | null>(null);
|
||||
const [spaceName, setSpaceName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('缺少 token 参数');
|
||||
return;
|
||||
}
|
||||
const base = getNotebookApiBaseURL();
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}`);
|
||||
return res.json() as Promise<NotebookTokenData>;
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
return fetch(`${base}/api/v1/searchspaces/${d.search_space_id}`, {
|
||||
headers: { Authorization: `Bearer ${d.user_token}` },
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{ name: string }>;
|
||||
})
|
||||
.then((space) => {
|
||||
if (space?.name) setSpaceName(space.name);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}, [token]);
|
||||
|
||||
// Keep-alive:每 50 秒 ping 一次 token 接口,防止 Redis TTL 过期
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const base = getNotebookApiBaseURL();
|
||||
const id = setInterval(() => {
|
||||
fetch(`${base}/api/v1/ai-writing/notebook-token/${token}`).catch(() => {});
|
||||
}, 50_000);
|
||||
return () => clearInterval(id);
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* 索财笔记 Header —— 只承载空间信息(品牌 / 右侧标签已移除) */}
|
||||
<div className="relative flex items-center gap-3 px-5 h-[52px] shrink-0 bg-gradient-to-r from-[#1e3a5f] to-[#1a3255] border-b border-[#2a4a70]">
|
||||
{/* 顶部极细品牌高光线 */}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-[#7fb3e8]/55 to-transparent" />
|
||||
|
||||
{/* 空间信息:精致玻璃胶囊 */}
|
||||
{data?.search_space_id && (
|
||||
<div
|
||||
className="inline-flex h-7 items-center gap-2 rounded-full border border-[#7fb3e8]/30 bg-gradient-to-b from-[#2d5080]/55 to-[#1a3a60]/55 pl-2 pr-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.06),0_1px_2px_rgba(0,0,0,0.18)] backdrop-blur-sm transition-all hover:border-[#7fb3e8]/55 hover:-translate-y-px hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_2px_10px_-2px_rgba(59,130,246,0.45)]"
|
||||
title={`当前空间:${spaceName ?? data.search_space_id}`}
|
||||
>
|
||||
{/* 图标徽标 */}
|
||||
<span className="flex size-[18px] items-center justify-center rounded-[5px] bg-gradient-to-br from-[#3b82f6] to-[#1d4ed8] shadow-[0_0_0_1px_rgba(127,179,232,0.25),0_2px_6px_-1px_rgba(29,78,216,0.6)]">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-[11px] text-[#e0f0ff]"
|
||||
>
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||||
<polyline points="2 17 12 22 22 17" />
|
||||
<polyline points="2 12 12 17 22 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-[11px] font-medium tracking-wide text-[#7fb3e8]">空间</span>
|
||||
<span className="h-3 w-px bg-[#7fb3e8]/25" />
|
||||
<span className="max-w-[200px] truncate text-[12.5px] font-semibold text-[#d6e8fb]">
|
||||
{spaceName ?? data.search_space_id}
|
||||
</span>
|
||||
{/* 已连接状态点 */}
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#4ade80] shadow-[0_0_0_2px_rgba(74,222,128,0.18)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹性占位 */}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center h-full text-red-400 text-sm">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="flex items-center justify-center h-full text-[#7fb3e8] text-sm">加载中…</div>
|
||||
) : (
|
||||
<AIWritingPage
|
||||
hideAdvancedFields
|
||||
initialNotebookId={data.search_space_id}
|
||||
initialDocumentIds={data.doc_ids}
|
||||
studioToken={data.user_token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import NotebookAIWritingPage from './NotebookAIWritingPage';
|
||||
|
||||
export default function NotebookRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="ai-writing" element={<NotebookAIWritingPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
import NotebookRoutes from "./NotebookRoutes";
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -68,6 +69,7 @@ function PageLayout() {
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
<Route path="roundtable/*" element={<RoundtableRoutes />} />
|
||||
<Route path="notebook/*" element={<NotebookRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -180,18 +180,18 @@ export default function StudioNotebooksPage() {
|
||||
<div className="min-h-full bg-background p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-foreground">笔记本</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">空间</h1>
|
||||
<button
|
||||
onClick={() => { setCreateOpen(true); setCreateError(""); setCreateName(""); }}
|
||||
className="flex items-center gap-1.5 rounded-full bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新建笔记本
|
||||
新建空间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-sm text-muted-foreground">加载笔记本失败,请稍后重试。</p>
|
||||
<p className="text-sm text-muted-foreground">加载空间失败,请稍后重试。</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
@@ -199,20 +199,20 @@ export default function StudioNotebooksPage() {
|
||||
{isLoading
|
||||
? Array.from({ length: 8 }).map((_, i) => <NotebookCardSkeleton key={i} />)
|
||||
: notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
nb={nb}
|
||||
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
|
||||
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 新建 Dialog ── */}
|
||||
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建笔记本</DialogTitle>
|
||||
<DialogTitle>新建空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<input
|
||||
@@ -220,7 +220,7 @@ export default function StudioNotebooksPage() {
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
|
||||
placeholder="笔记本名称"
|
||||
placeholder="空间名称"
|
||||
disabled={creating}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
/>
|
||||
@@ -239,7 +239,7 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!renameTarget} onOpenChange={(v) => !renaming && !v && setRenameTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名笔记本</DialogTitle>
|
||||
<DialogTitle>重命名空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -272,12 +272,12 @@ export default function StudioNotebooksPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(v) => !deleting && !v && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除笔记本</DialogTitle>
|
||||
<DialogTitle>删除空间</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除{deleteTarget ? <span className="font-medium text-foreground">「{deleteTarget.name}」</span> : ""}吗?
|
||||
删除后笔记本内所有来源数据将一并移除,且无法恢复。
|
||||
删除后空间内所有来源数据将一并移除,且无法恢复。
|
||||
</p>
|
||||
{deleteError && <p className="text-xs text-destructive">{deleteError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
@@ -2762,14 +2762,15 @@ class CuratorConfigResponse(BaseModel):
|
||||
|
||||
|
||||
class CuratorConfigRequest(BaseModel):
|
||||
"""Payload for updating skill_evolution overrides.
|
||||
"""Payload for updating user-facing curator settings in skill_evolution.json.
|
||||
|
||||
All fields are required so the frontend always sends a complete config;
|
||||
backend writes the entire payload to skill_evolution.json atomically.
|
||||
All other settings (enabled, timeouts) are global-only and controlled via config.yaml.
|
||||
"""
|
||||
|
||||
enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.")
|
||||
curator: CuratorSubConfig
|
||||
interval_hours: int = Field(..., ge=1, description="Hours between curator runs.")
|
||||
stale_after_days: int = Field(..., ge=1, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(..., ge=1, description="Days of inactivity before a stale skill is archived.")
|
||||
model_name: str | None = Field(default=None, description="Model for the curator LLM review; null = primary model.")
|
||||
|
||||
|
||||
class CuratorLogRecord(BaseModel):
|
||||
@@ -2785,11 +2786,7 @@ class CuratorLogsResponse(BaseModel):
|
||||
|
||||
|
||||
def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
|
||||
|
||||
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
|
||||
overrides if any).
|
||||
"""
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape."""
|
||||
return CuratorConfigResponse(
|
||||
enabled=evo.enabled,
|
||||
curator=CuratorSubConfig(
|
||||
@@ -2805,8 +2802,7 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
|
||||
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
|
||||
async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
||||
"""Return the current curator config for the authenticated user (yaml
|
||||
defaults + that user's overrides)."""
|
||||
"""Return the current curator config for the authenticated user (yaml defaults + per-user overrides)."""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
|
||||
@@ -2819,21 +2815,22 @@ async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
||||
|
||||
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
|
||||
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
|
||||
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
|
||||
and return the effective merged config.
|
||||
"""Persist the three user-facing curator thresholds to the per-user ``skill_evolution.json``.
|
||||
|
||||
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
|
||||
fields are rejected.
|
||||
Other settings (enabled, model, timeouts) are global and controlled via config.yaml.
|
||||
"""
|
||||
try:
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
payload = body.model_dump()
|
||||
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
|
||||
SkillEvolutionConfig.model_validate(payload)
|
||||
save_user_overrides(user_id, payload)
|
||||
save_user_overrides(user_id, {
|
||||
"curator": {
|
||||
"interval_hours": body.interval_hours,
|
||||
"stale_after_days": body.stale_after_days,
|
||||
"archive_after_days": body.archive_after_days,
|
||||
"model_name": body.model_name,
|
||||
},
|
||||
})
|
||||
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -10,90 +10,104 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-1520028668cb4cc388ed8315ff1e856e
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-flash
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-v4-pro
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
# - name: MiniMax-M2.7
|
||||
# display_name: MiniMax-M2.7
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: MiniMax-M2.7
|
||||
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
# base_url: https://api.minimaxi.com/v1
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: glm-5.1
|
||||
# display_name: glm-5.1
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: glm-5.1
|
||||
# api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
# base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: deepseek-ai/DeepSeek-OCR
|
||||
# display_name: deepseek-ai/DeepSeek-OCR
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-ai/DeepSeek-OCR
|
||||
# api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
# base_url: https://api.siliconflow.cn/v1/
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: zai-org/GLM-5-FP8
|
||||
# api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
# base_url: https://api.us-west-2.modal.direct/v1
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
model: deepseek-chat
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
|
||||
|
||||
- name: deepseek-v4-flash
|
||||
display_name: DeepSeek V4 Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-v4-flash
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: deepseek-v4-pro
|
||||
display_name: DeepSeek V4 Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-v4-pro
|
||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
- name: MiniMax-M2.7
|
||||
display_name: MiniMax-M2.7
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: MiniMax-M2.7
|
||||
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
|
||||
base_url: https://api.minimaxi.com/v1
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: glm-5.1
|
||||
display_name: glm-5.1
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: glm-5.1
|
||||
api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR
|
||||
base_url: https://open.bigmodel.cn/api/paas/v4/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-OCR
|
||||
display_name: deepseek-ai/DeepSeek-OCR
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-OCR
|
||||
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: Qwen/Qwen3.6-27B
|
||||
display_name: Qwen/Qwen3.6-27B
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: Qwen/Qwen3.6-27B
|
||||
api_key: sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
@@ -122,26 +136,6 @@ uploads:
|
||||
auto_convert_documents: true
|
||||
pdf_converter: auto
|
||||
|
||||
# Chrome 插件「当前网页发送到 Deer」入口。默认关闭,打开后才启用
|
||||
# /api/browser-context/capture。它只保存当前页 Markdown 到线程 uploads,
|
||||
# 不会改变普通聊天、上传或工具调用流程。
|
||||
browser_context:
|
||||
enabled: true
|
||||
max_content_chars: 200000
|
||||
max_title_chars: 300
|
||||
max_url_chars: 2048
|
||||
filename_prefix: webpage
|
||||
route_base: /page/workspace
|
||||
task_result_timeout_seconds: 60
|
||||
task_poll_interval_seconds: 1
|
||||
task_lease_seconds: 120
|
||||
tool_result_max_chars: 60000
|
||||
# 交互式页面操作(browser_act:点击/输入/滚动/等待/跳转)。关闭后只保留只读抓取。
|
||||
actions_enabled: true
|
||||
action_result_timeout_seconds: 45
|
||||
max_observed_elements: 150
|
||||
observation_text_max_chars: 12000
|
||||
|
||||
title:
|
||||
enabled: true
|
||||
max_words: 6
|
||||
@@ -159,7 +153,6 @@ title:
|
||||
|
||||
tool_groups:
|
||||
- name: web
|
||||
- name: browser
|
||||
- name: file:read
|
||||
- name: file:write
|
||||
- name: bash
|
||||
@@ -175,14 +168,6 @@ tools:
|
||||
use: deerflow.community.jina_ai.tools:web_fetch_tool
|
||||
timeout: 15
|
||||
|
||||
- name: browser_fetch_page
|
||||
group: browser
|
||||
use: deerflow.tools.builtins.browser_context_tool:browser_fetch_page_tool
|
||||
|
||||
- name: browser_act
|
||||
group: browser
|
||||
use: deerflow.tools.builtins.browser_act_tool:browser_act_tool
|
||||
|
||||
- name: ls
|
||||
group: file:read
|
||||
use: deerflow.sandbox.tools:ls_tool
|
||||
@@ -222,6 +207,18 @@ skills:
|
||||
security_scan:
|
||||
enabled: false
|
||||
|
||||
skill_evolution:
|
||||
enabled: true
|
||||
curator:
|
||||
enabled: true # 每 24 小时自动清理 + 合并 skill
|
||||
interval_hours: 24
|
||||
review_worker:
|
||||
enabled: true # 被动复盘开关
|
||||
min_tool_calls: 5 # 触发阈值:本轮至少 5 次工具调用,与 prompt 中"≥5 步"硬条件一致
|
||||
max_messages: 60 # 传给 LLM 的最大消息条数
|
||||
timeout_seconds: 120 # 单次 review 超时(秒)
|
||||
model_name: # 留空则使用主模型
|
||||
|
||||
agents_api:
|
||||
enabled: true
|
||||
|
||||
@@ -252,7 +249,7 @@ auth_login:
|
||||
|
||||
database:
|
||||
backend: sqlite
|
||||
sqlite_dir: /app/backend/.deer-flow/data
|
||||
sqlite_dir: .deer-flow/data
|
||||
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
||||
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
||||
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
||||
@@ -260,7 +257,7 @@ database:
|
||||
|
||||
checkpointer:
|
||||
type: sqlite
|
||||
connection_string: /app/backend/.deer-flow/data/deerflow.db
|
||||
connection_string: .deer-flow/data/deerflow.db
|
||||
|
||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||
# 三层覆盖优先级(高->低):
|
||||
@@ -342,39 +339,3 @@ log_system:
|
||||
enabled: true
|
||||
url: "https://your-log-system.example.com"
|
||||
label: "日志系统"
|
||||
|
||||
# 产品内知识库 —— obsidian-wiki 兼容 vault。知识元数据写入数据库,正文
|
||||
# content_md 写入 Markdown vault(index.md/log.md/hot.md/.manifest.json/frontmatter/
|
||||
# wikilinks/wiki-export 图谱产物)。知识库为全局共享,不做 user_id 隔离,
|
||||
# 写入时记录 created_by / updated_by 作为审计。
|
||||
knowledge:
|
||||
enabled: true
|
||||
provider: obsidian_wiki
|
||||
vault_dir: data/knowledge/obsidian-vault
|
||||
project_name: zncm
|
||||
auto_ingest_enabled: false
|
||||
auto_ingest_min_chars: 300
|
||||
# 用大模型按 wiki-capture 规范提炼陈述式知识(而非堆砌对话原文)。
|
||||
# 关闭则回退到规则抽取;extract_model_name 留空时用 models[0]。
|
||||
llm_extract_enabled: true
|
||||
extract_model_name: ""
|
||||
extract_max_input_chars: 12000
|
||||
max_thread_messages: 80
|
||||
max_source_items: 20
|
||||
rag_enabled: false
|
||||
rag_limit: 5
|
||||
# 阶段三:写入前脱敏(token/密钥/手机号/身份证/邮箱);重复检测相似度阈值。
|
||||
sensitive_redaction_enabled: true
|
||||
dedup_similarity_threshold: 0.82
|
||||
export:
|
||||
enabled: true
|
||||
graph_html_enabled: true
|
||||
graph_json_enabled: true
|
||||
query:
|
||||
use_hot_md: true
|
||||
use_index_md: true
|
||||
use_frontmatter_scan: true
|
||||
embedding:
|
||||
enabled: false
|
||||
provider: local
|
||||
model: ""
|
||||
+42
-9
@@ -34,17 +34,20 @@
|
||||
### 关键事件
|
||||
|
||||
**1987.11 开放赴中国大陆探亲**:
|
||||
|
||||
- 蒋经国政府宣布开放赴大陆探亲
|
||||
- 表面为人道措施,实质开启人员与资金流动
|
||||
- "探亲带带货"成为早期贸易雏形
|
||||
|
||||
**1990s 间接贸易兴起**:
|
||||
|
||||
- 贸易通过香港等第三地转口进行
|
||||
- 台商以个人或第三地公司名义登陆
|
||||
- 鞋业、成衣、玩具等劳力密集产业率先外移
|
||||
- 1992 邓小平南巡后,台商投资第一波高峰
|
||||
|
||||
**1996.09 戒急用忍政策**:
|
||||
|
||||
- 李登辉提出"戒急用忍"
|
||||
- 限制高科技与大型基础设施赴大陆投资
|
||||
- 试图在政治风险上升时踩刹车
|
||||
@@ -77,23 +80,27 @@
|
||||
### 关键事件
|
||||
|
||||
**2001.01 金马小三通**:
|
||||
|
||||
- 金门、马祖与福建沿海通航通商
|
||||
- 象征性大于实质经济意义
|
||||
- 但打破了"完全不直接通商"的禁区
|
||||
|
||||
**2001.11 两岸加入 WTO**:
|
||||
|
||||
- 台湾以"台澎金马个别关税领域"名义加入
|
||||
- 北京以"主权国家"身份加入
|
||||
- 加入 WTO 后,间接贸易的合法性基础松动
|
||||
- 台北面临"是否开放直航与直接投资"的压力
|
||||
|
||||
**2001-2004 积极开放有效管理**:
|
||||
|
||||
- 陈水扁政府调整"戒急用忍"为"积极开放有效管理"
|
||||
- 放宽投资上限(8千万新台币限制放宽)
|
||||
- 但 2006 后又收紧为"积极管理有效开放"
|
||||
|
||||
**2000s 中期 IT 产业大举西进**:
|
||||
- 笔记本电脑、半导体封装测试等中下游产业外移
|
||||
|
||||
- 空间电脑、半导体封装测试等中下游产业外移
|
||||
- 形成台湾接单、大陆生产、出口欧美的三角贸易结构
|
||||
- 台湾对大陆出口依存度突破 20%
|
||||
|
||||
@@ -125,11 +132,13 @@
|
||||
### 关键事件
|
||||
|
||||
**2008.07 周末包机与大陆观光客来台**:
|
||||
|
||||
- 两岸周末包机启动
|
||||
- 大陆观光客来台(首波仅限特定省市)
|
||||
- 大三通的前奏
|
||||
|
||||
**2008.12 大三通**:
|
||||
|
||||
- 海运直航、空运直航、直接通邮
|
||||
- 两岸经贸进入"直接"时代
|
||||
- 物流成本大幅下降,供应链整合加速
|
||||
@@ -137,6 +146,7 @@
|
||||
详见 `entities/people/ma_ying_jeou.md`。
|
||||
|
||||
**2010.06 ECFA 签订**:
|
||||
|
||||
- 两岸经济合作架构协议(ECFA)签署
|
||||
- 包含"早收清单"(539 项台湾货品降税)
|
||||
- 后续需谈判"货品贸易协议"与"服务贸易协议"
|
||||
@@ -145,6 +155,7 @@
|
||||
详见 `narratives/cross_strait/ecfa_legacy_debate.md`。
|
||||
|
||||
**2013.06 两岸服贸协议签署**:
|
||||
|
||||
- 服务贸易协议完成签署
|
||||
- 开放金融、医疗、电信等服务领域
|
||||
- 但需经立法院审查,尚未生效
|
||||
@@ -177,6 +188,7 @@
|
||||
### 关键事件
|
||||
|
||||
**2014.03-04 太阳花学运**:
|
||||
|
||||
- 反对服贸协议黑箱审查
|
||||
- 占领立法院 24 天
|
||||
- 服贸协议实质冻结
|
||||
@@ -185,11 +197,13 @@
|
||||
详见 `events/key_events/event_sunflower_2014.md`。
|
||||
|
||||
**2014.11 九合一选举**:
|
||||
|
||||
- 国民党大败
|
||||
- 经济整合路线的选举惩罚
|
||||
- 两岸政策从"积极整合"转向"保守防御"
|
||||
|
||||
**2015**:
|
||||
|
||||
- 两岸经贸谈判全面停滞
|
||||
- 台商投资持续但增速放缓
|
||||
- 青年创业成为新的交流形式(但规模有限)
|
||||
@@ -224,26 +238,31 @@
|
||||
### 关键事件
|
||||
|
||||
**2016.05 蔡英文上任**:
|
||||
|
||||
- 承认"九二会谈"但不承认"九二共识"
|
||||
- 北京中断官方沟通机制
|
||||
- 两岸经贸进入"冷对抗"状态
|
||||
|
||||
**2016-2020 新南向政策**:
|
||||
|
||||
- 鼓励台商投资东南亚与南亚
|
||||
- 对大陆出口依存度短暂下降后回升
|
||||
- 投资分散有限——2020 年对大陆投资仍占对外投资 40% 以上
|
||||
|
||||
**2018-2019 美中贸易战**:
|
||||
|
||||
- 台商回流与转单效应
|
||||
- 台商将部分产能移回台湾或转至东南亚
|
||||
- 但高阶制程仍在台湾,中低阶仍在中国大陆
|
||||
|
||||
**2020-2022 疫情与供应链重组**:
|
||||
|
||||
- 台湾对中国大陆出口依存度反而上升(因远程办公需求与大陆复工较快)
|
||||
- 2021 年对大陆出口依存度达 42.3%(历史新高)
|
||||
- 台积电等高阶半导体成为战略资产
|
||||
|
||||
**2022.08 裴洛西访台后大陆暂停部分农产品输入**:
|
||||
|
||||
- 柑橘类、冰鲜白带鱼、冻竹荚鱼等被暂停输入
|
||||
- 目标为台湾中南部农渔业
|
||||
- 经济制裁工具化的初步展现
|
||||
@@ -276,30 +295,36 @@
|
||||
### 关键事件
|
||||
|
||||
**2023.04 贸易壁垒调查**:
|
||||
|
||||
- 北京宣布对台湾 2455 项产品进行贸易壁垒调查
|
||||
- 涵盖农产品、五矿化工、纺织品等
|
||||
- 这是 ECFA 架构外的系统性施压
|
||||
|
||||
**2023.12 ECFA 部分中止**:
|
||||
|
||||
- 北京宣布中止 ECFA 早收清单中 12 项石化产品关税减让
|
||||
- 丙烯、聚碳酸酯等受影响
|
||||
- 明示与台湾选举结果的政治连动
|
||||
|
||||
**2024.01 台湾总统选举**:
|
||||
|
||||
- 赖清德当选
|
||||
- 北京在选举后继续 ECFA 调整
|
||||
|
||||
**2024.05-06 ECFA 第二波中止**:
|
||||
|
||||
- 中止 134 项产品关税减让(机械、纺织、汽车零组件等)
|
||||
- ECFA 早收清单 539 项中已有近 30% 被中止
|
||||
- 同时暂停台湾芒果等农产品输入
|
||||
|
||||
**2024.09-10 进一步施压**:
|
||||
|
||||
- 北京宣布对台 34 项农产品停止零关税
|
||||
- 涵盖水果、蔬菜、水产品
|
||||
- 精准打击中南部农渔业
|
||||
|
||||
**2024-2025 持续的"边打边留"**:
|
||||
|
||||
- 北京未全面废除 ECFA——保留部分杠杆
|
||||
- 高科技产业(半导体)未被直接制裁——避免自我伤害
|
||||
- 传统产业与农业成为主要打击目标
|
||||
@@ -328,14 +353,14 @@
|
||||
|
||||
### 变化一:政经分离的从可能到不可能
|
||||
|
||||
| 时期 | 台北的政经分离策略 | 北京的政经合一策略 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 时期 | 台北的政经分离策略 | 北京的政经合一策略 | 结果 |
|
||||
| --------- | ------------------ | -------------------- | ------------------ |
|
||||
| 1987-1999 | 间接贸易、行政管制 | 默许民间交流以经促统 | 表面分离,实质连结 |
|
||||
| 2000-2007 | 积极开放有效管理 | 吸引台资建立依赖 | 管制失效,依赖加深 |
|
||||
| 2008-2013 | 先经后政 | 让利铺路 | 制度化整合 |
|
||||
| 2014-2015 | 阻断整合 | 让利策略失效 | 分离再确认 |
|
||||
| 2016-2022 | 经贸不扩展、新南向 | 有限度制裁 | 被动脱钩 |
|
||||
| 2023- | 防御性脱钩 | 以经逼政、边打边留 | 强制政经合一 |
|
||||
| 2000-2007 | 积极开放有效管理 | 吸引台资建立依赖 | 管制失效,依赖加深 |
|
||||
| 2008-2013 | 先经后政 | 让利铺路 | 制度化整合 |
|
||||
| 2014-2015 | 阻断整合 | 让利策略失效 | 分离再确认 |
|
||||
| 2016-2022 | 经贸不扩展、新南向 | 有限度制裁 | 被动脱钩 |
|
||||
| 2023- | 防御性脱钩 | 以经逼政、边打边留 | 强制政经合一 |
|
||||
|
||||
**分析含义**:政经分离在两岸关系中**结构性不可能**——因为北京始终将经济作为政治工具,而台北无法单方面维持分离。
|
||||
|
||||
@@ -370,26 +395,31 @@
|
||||
分析两岸经贸关系,**持续观察以下指标**:
|
||||
|
||||
### 指标一:ECFA 存续状态
|
||||
|
||||
- 早收清单被中止的比例
|
||||
- 北京是否全面废除 ECFA
|
||||
- 台北是否主动退出 ECFA
|
||||
|
||||
### 指标二:贸易依存度变化
|
||||
|
||||
- 台湾对大陆(含香港)出口占总出口比例
|
||||
- 关键产业(石化、机械、电子零组件)的出口结构变化
|
||||
- 农产品出口市场的分散程度
|
||||
|
||||
### 指标三:投资流向
|
||||
|
||||
- 台商对大陆投资金额与件数
|
||||
- 台商回台投资金额与产业别
|
||||
- 新南向国家的台商投资趋势
|
||||
|
||||
### 指标四:制裁与反制裁的升级/降级
|
||||
|
||||
- 北京制裁的产业范围与力度
|
||||
- 台北的反制措施(如对大陆产品的限制)
|
||||
- 双方是否在 WTO 框架内解决争端
|
||||
|
||||
### 指标五:高科技供应链的连结与脱钩
|
||||
|
||||
- 半导体设备与技术的出口管制
|
||||
- 台积电等关键企业在两岸的布局
|
||||
- 美国科技管制对两岸经贸的间接影响
|
||||
@@ -399,16 +429,19 @@
|
||||
## 未来观察点(待追踪事件)
|
||||
|
||||
### 2025-2026 ECFA 的最终走向
|
||||
|
||||
- 北京是否进一步中止早收清单项目
|
||||
- ECFA 是否名存实亡或正式终止
|
||||
- 台湾传统产业的转型与淘汰
|
||||
|
||||
### 2028 前后的经贸政策辩论
|
||||
|
||||
- 各候选人的两岸经贸政见
|
||||
- "重新谈判"vs"彻底脱钩"的路线竞争
|
||||
- 北京是否以经济让利介入选举
|
||||
|
||||
### 非红供应链的成型程度
|
||||
|
||||
- 台湾、东南亚、印度的供应链替代能力
|
||||
- 台商在大陆的"在地化"程度(是否可被替代)
|
||||
- 关键原材料的替代来源
|
||||
@@ -502,4 +535,4 @@ ECFA 只是关税减让框架,中止 ECFA 不等于停止贸易——**最惠
|
||||
|
||||
last_updated: 2026-05-08
|
||||
|
||||
**注**:本档案为活档案——需随事件持续更新,特别是 ECFA 调整与供应链重组的动态。
|
||||
**注**:本档案为活档案——需随事件持续更新,特别是 ECFA 调整与供应链重组的动态。
|
||||
+55
-5
@@ -109,20 +109,42 @@ async def _search_notebook(
|
||||
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
|
||||
import httpx # 懒导入:仅在笔记模式下才需要
|
||||
|
||||
# 入参全 dump(token 只打长度,不打明文,防止泄漏到日志)
|
||||
logger.info(
|
||||
"[notebook-search] called keywords=%s search_space_id=%s source_ids=%s token_len=%d",
|
||||
keywords,
|
||||
search_space_id,
|
||||
source_ids,
|
||||
len(token) if token else 0,
|
||||
)
|
||||
|
||||
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||
if not base:
|
||||
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
|
||||
# 改成 error 级别,warning 在很多部署里默认会被过滤掉看不到
|
||||
logger.error(
|
||||
"[notebook-search] DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search. "
|
||||
"请在后端启动环境里设这个环境变量(指向笔记后端根地址,例如 http://localhost:8003)。"
|
||||
)
|
||||
return []
|
||||
|
||||
full_url = f"{base}{_NOTEBOOK_SEARCH_PATH}"
|
||||
logger.info("[notebook-search] base=%s full_url=%s", base, full_url)
|
||||
|
||||
results: list[Material] = []
|
||||
mat_idx = 0
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
|
||||
logger.info("[notebook-search] POST kw=%r body=%s", kw, {
|
||||
"query": kw,
|
||||
"source_ids": source_ids,
|
||||
"search_space_id": search_space_id,
|
||||
"top_k": _NOTEBOOK_TOP_K,
|
||||
})
|
||||
try:
|
||||
# /mentioned-documents/search 接口无需鉴权,不传 Authorization header
|
||||
resp = await client.post(
|
||||
f"{base}{_NOTEBOOK_SEARCH_PATH}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
full_url,
|
||||
json={
|
||||
"query": kw,
|
||||
"source_ids": source_ids,
|
||||
@@ -130,10 +152,29 @@ async def _search_notebook(
|
||||
"top_k": _NOTEBOOK_TOP_K,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"[notebook-search] kw=%r http_status=%d body_len=%d",
|
||||
kw,
|
||||
resp.status_code,
|
||||
len(resp.content or b""),
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
# 把错误响应体打出来,方便定位 401 / 422 等
|
||||
logger.error(
|
||||
"[notebook-search] kw=%r HTTP %d response: %s",
|
||||
kw,
|
||||
resp.status_code,
|
||||
(resp.text or "")[:1000],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
# 解析 documents 列表,每个 chunk 作为独立素材
|
||||
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
|
||||
logger.info(
|
||||
"[notebook-search] kw=%r parsed documents=%d",
|
||||
kw,
|
||||
len(documents or []),
|
||||
)
|
||||
for doc in documents or []:
|
||||
doc_title: str = doc.get("title") or "笔记文档"
|
||||
doc_url: str = doc.get("url") or ""
|
||||
@@ -154,10 +195,11 @@ async def _search_notebook(
|
||||
url=doc_url,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("notebook search kw=%r failed: %s", kw, e)
|
||||
logger.warning("[notebook-search] kw=%r failed: %s", kw, e, exc_info=True)
|
||||
except Exception as e:
|
||||
logger.warning("notebook search client error: %s", e)
|
||||
logger.warning("[notebook-search] client error: %s", e, exc_info=True)
|
||||
|
||||
logger.info("[notebook-search] done — returning %d materials", len(results))
|
||||
return results
|
||||
|
||||
|
||||
@@ -250,6 +292,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
|
||||
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
|
||||
notebook_materials: list[Material] = []
|
||||
|
||||
if notebook_search_space_id:
|
||||
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
|
||||
notebook_token: str = cfg.get("notebook_token") or ""
|
||||
@@ -369,6 +412,13 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
# 技能模式不再走下面的并发/合并逻辑,all_results_nested 留空、跳过 web 通用搜索
|
||||
all_results_nested: list = []
|
||||
# 把已收集到的素材挂在 raw_materials_pre,下方合并阶段直接挪过去
|
||||
elif material_source == "notebook" and notebook_search_space_id:
|
||||
# ── 纯笔记模式 ──
|
||||
# 用户在表单选了「我的空间」作为素材来源 → 跳过 web/intranet 检索,
|
||||
# 仅使用 Step 1.5 已拉取的 notebook_materials。
|
||||
# 与 UI 文案"从空间中检索素材"对齐。
|
||||
_emit(f"仅使用笔记素材,跳过 web/intranet 检索({len(notebook_materials)} 条)")
|
||||
all_results_nested = []
|
||||
else:
|
||||
# ── 常规 web/intranet 模式 ──
|
||||
provider = get_search_provider(ai_writing_config)
|
||||
|
||||
@@ -331,6 +331,13 @@ def _build_middlewares(
|
||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||
middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents))
|
||||
|
||||
# SkillReviewMiddleware — trigger background skill review after each turn.
|
||||
# Gated by skill_evolution.enabled + skill_evolution.review_worker.enabled.
|
||||
if resolved_app_config.skill_evolution.enabled and resolved_app_config.skill_evolution.review_worker.enabled:
|
||||
from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware
|
||||
|
||||
middlewares.append(SkillReviewMiddleware())
|
||||
|
||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||
middlewares.append(LoopDetectionMiddleware())
|
||||
|
||||
|
||||
+2
-2
@@ -1078,7 +1078,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
|
||||
<notebook_mode>
|
||||
## 角色
|
||||
你是一个笔记本 AI 助手,**只能**基于笔记本文档来源回答用户问题,禁止使用任何其他知识。
|
||||
你是一个空间 AI 助手,**只能**基于空间文档来源回答用户问题,禁止使用任何其他知识。
|
||||
|
||||
## 可用知识来源
|
||||
{source_line}
|
||||
@@ -1104,7 +1104,7 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
- 未调用 `search_notebook_sources` 就直接回答
|
||||
- 引用不在检索结果中的 chunk_id
|
||||
- 省略引用标记
|
||||
- 使用笔记本来源之外的知识
|
||||
- 使用空间来源之外的知识
|
||||
</notebook_mode>"""
|
||||
|
||||
return prompt
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""SkillReviewMiddleware — triggers background skill review after each agent turn.
|
||||
|
||||
Hooks into ``after_agent``: once the lead agent graph has finished a full turn,
|
||||
this middleware counts the tool calls made and, if the threshold is met, hands
|
||||
the conversation messages to the SkillReviewWorker as a background asyncio task.
|
||||
|
||||
No blocking I/O or LLM calls happen in-band — the review runs fully in the
|
||||
background and never delays the HTTP response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.review_worker import maybe_start_review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkillReviewMiddlewareState(AgentState):
|
||||
"""Minimal state schema — only needs ``messages``."""
|
||||
pass
|
||||
|
||||
|
||||
class SkillReviewMiddleware(AgentMiddleware[SkillReviewMiddlewareState]):
|
||||
"""Dispatch a background skill review after every substantial agent turn.
|
||||
|
||||
'Substantial' is defined by ``skill_evolution.review_worker.min_tool_calls``
|
||||
(default 5). Turns below the threshold are silently skipped to avoid
|
||||
wasting LLM tokens on trivial exchanges.
|
||||
"""
|
||||
|
||||
state_schema = SkillReviewMiddlewareState
|
||||
|
||||
@override
|
||||
def after_agent(self, state: Any, runtime: Runtime) -> None:
|
||||
"""Fire background review; never raises — failures are logged only."""
|
||||
try:
|
||||
app_config = get_app_config()
|
||||
messages = state.get("messages", []) if hasattr(state, "get") else []
|
||||
thread_id: str = runtime.context.get("thread_id", "") if runtime.context else ""
|
||||
if not thread_id:
|
||||
return
|
||||
user_id = get_effective_user_id()
|
||||
maybe_start_review(thread_id, messages, app_config, user_id)
|
||||
except Exception:
|
||||
logger.exception("SkillReviewMiddleware.after_agent: unexpected error (ignored)")
|
||||
+1
-1
@@ -107,7 +107,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]):
|
||||
content=last_message.content,
|
||||
id=last_message.id,
|
||||
name=last_message.name,
|
||||
additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
|
||||
additional_kwargs={**last_message.additional_kwargs, "run_id": (runtime.context or {}).get("run_id"), "timestamp": datetime.now(UTC).isoformat()},
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
+39
-30
@@ -1,30 +1,39 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CuratorConfig(BaseModel):
|
||||
"""Configuration for the background skill curator."""
|
||||
|
||||
# 默认关闭:curator 仅在用户显式开启时才运行(配合 SkillEvolutionConfig.enabled
|
||||
# 总开关,二者均为安全默认 False —— 未显式 opt-in 则整套技能进化不动作)。
|
||||
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
|
||||
interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).")
|
||||
stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.")
|
||||
# LLM 评审整体超时:agent.invoke 内部可能多轮调 LLM,模型层的单次 request_timeout
|
||||
# 兜不住整体;超过此秒数则中止本次评审,curator 正常收尾并释放运行锁。
|
||||
review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.")
|
||||
# curator LLM 评审使用的模型名(对应 config.yaml models[].name);为空则用主模型。
|
||||
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.")
|
||||
|
||||
|
||||
class SkillEvolutionConfig(BaseModel):
|
||||
"""Configuration for agent-managed skill evolution."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the agent can create and modify skills under skills/custom.",
|
||||
)
|
||||
curator: CuratorConfig = Field(
|
||||
default_factory=CuratorConfig,
|
||||
description="Configuration for the background skill curator.",
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ReviewWorkerConfig(BaseModel):
|
||||
"""Configuration for the post-turn background skill review worker."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether the review worker is active.")
|
||||
min_tool_calls: int = Field(default=5, description="Minimum tool calls in a turn to trigger review. Matches the prompt's '≥5 steps' hard condition so simple queries never reach the LLM reviewer.")
|
||||
max_messages: int = Field(default=60, description="Max messages passed to the review LLM.")
|
||||
model_name: str | None = Field(default=None, description="Model to use for review; defaults to primary model.")
|
||||
timeout_seconds: int = Field(default=120, description="Overall timeout for a single review run.")
|
||||
|
||||
|
||||
class CuratorConfig(BaseModel):
|
||||
"""Configuration for the background skill curator."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether the curator runs automatically.")
|
||||
interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).")
|
||||
stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.")
|
||||
archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.")
|
||||
review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.")
|
||||
model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.")
|
||||
|
||||
|
||||
class SkillEvolutionConfig(BaseModel):
|
||||
"""Configuration for agent-managed skill evolution."""
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the agent can create and modify skills under skills/custom.",
|
||||
)
|
||||
curator: CuratorConfig = Field(
|
||||
default_factory=CuratorConfig,
|
||||
description="Configuration for the background skill curator.",
|
||||
)
|
||||
review_worker: ReviewWorkerConfig = Field(
|
||||
default_factory=ReviewWorkerConfig,
|
||||
description="Configuration for the post-turn background skill review worker.",
|
||||
)
|
||||
+19
-2
@@ -135,8 +135,18 @@ def resolve_user_path(user_id: str) -> Path:
|
||||
return get_paths().user_dir(user_id) / _FILENAME
|
||||
|
||||
|
||||
# Only these curator sub-fields may be overridden per user.
|
||||
# All other fields (enabled, model_name, timeouts, etc.) are global-only.
|
||||
_USER_ALLOWED_CURATOR_KEYS = {"interval_hours", "stale_after_days", "archive_after_days", "model_name"}
|
||||
|
||||
|
||||
def load_user_overrides(user_id: str) -> dict[str, Any] | None:
|
||||
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid)."""
|
||||
"""Read the per-user overrides file for ``user_id`` (None if missing/invalid).
|
||||
|
||||
Only the three curator threshold keys are honoured; any other keys
|
||||
(e.g. legacy ``enabled`` or ``model_name``) are silently stripped so that
|
||||
old files written by previous versions cannot override global-only settings.
|
||||
"""
|
||||
path = resolve_user_path(user_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -154,7 +164,14 @@ def load_user_overrides(user_id: str) -> dict[str, Any] | None:
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("User skill_evolution overrides at %s must be a JSON object", path)
|
||||
return None
|
||||
return data
|
||||
# Strip top-level keys that are not user-configurable (e.g. legacy "enabled").
|
||||
curator_raw = data.get("curator")
|
||||
if not isinstance(curator_raw, dict):
|
||||
return None
|
||||
filtered_curator = {k: v for k, v in curator_raw.items() if k in _USER_ALLOWED_CURATOR_KEYS}
|
||||
if not filtered_curator:
|
||||
return None
|
||||
return {"curator": filtered_curator}
|
||||
|
||||
|
||||
def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path:
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Post-turn background skill review worker.
|
||||
|
||||
After a conversation turn completes, SkillReviewMiddleware calls
|
||||
``maybe_start_review()``. This module runs a lightweight LLM React agent
|
||||
(with skill_manage + skill_list tools) that reads the conversation history
|
||||
and decides whether to create or patch skills.
|
||||
|
||||
Concurrency model: per-thread asyncio.Lock + a single _pending slot.
|
||||
If a review is already running for the thread, the newer messages overwrite
|
||||
the pending slot; once the current review finishes, it drains that slot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_locks: dict[str, asyncio.Lock] = {}
|
||||
_pending: dict[str, list[BaseMessage]] = {}
|
||||
|
||||
|
||||
def _get_lock(thread_id: str) -> asyncio.Lock:
|
||||
if thread_id not in _locks:
|
||||
_locks[thread_id] = asyncio.Lock()
|
||||
return _locks[thread_id]
|
||||
|
||||
|
||||
def extract_tool_call_count(messages: list[BaseMessage]) -> int:
|
||||
"""Count total tool calls across all AI messages in the conversation."""
|
||||
count = 0
|
||||
for msg in messages:
|
||||
tool_calls: Any = None
|
||||
if hasattr(msg, "tool_calls"):
|
||||
tool_calls = msg.tool_calls
|
||||
elif isinstance(msg, dict):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
count += len(tool_calls)
|
||||
return count
|
||||
|
||||
|
||||
def _format_messages_for_review(messages: list[BaseMessage], max_messages: int) -> str:
|
||||
"""Serialize recent messages to a readable text block for the LLM reviewer."""
|
||||
recent = messages[-max_messages:]
|
||||
lines: list[str] = []
|
||||
for msg in recent:
|
||||
role = type(msg).__name__.replace("Message", "")
|
||||
content = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if tool_calls:
|
||||
calls_str = ", ".join(
|
||||
f"{tc.name if hasattr(tc, 'name') else tc.get('name', '?')}()"
|
||||
for tc in tool_calls
|
||||
)
|
||||
lines.append(f"[{role}] <tool_calls: {calls_str}>")
|
||||
else:
|
||||
preview = content[:400].replace("\n", " ")
|
||||
lines.append(f"[{role}] {preview}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\
|
||||
# Role: 技能评审代理(保守模式)
|
||||
|
||||
## Profile
|
||||
- language: zh-CN
|
||||
- description: 审阅已完成对话,仅在出现强证据的高价值可复用工作流时才沉淀为技能。**默认决策是 do-nothing** —— 宁可漏掉一个普通技能,也绝不创建垃圾技能。
|
||||
- background: 历史问题:之前版本触发过于宽松,沉淀了大量低价值技能,污染了技能库。本版本针对性收紧。
|
||||
- personality: 极度保守、苛刻挑剔、宁缺勿滥;面对模糊场景默认 do-nothing。
|
||||
- expertise: 高价值工作流识别、严格去重、SKILL.md 规范、do-nothing 判定。
|
||||
- target_audience: 技能库维护者、平台工程师。
|
||||
|
||||
## Skills
|
||||
|
||||
1. 工作流抽象与评审
|
||||
- 价值评估: 区分一次性数据请求与具有沉淀价值的工作流。
|
||||
- 去数据化归纳: 抽象出与具体数据无关的通用步骤、参数与注意事项。
|
||||
- 决策映射: 在 do-nothing / patch / create 三者中做出可解释的唯一决定。
|
||||
|
||||
2. 文档与规范控制
|
||||
- SKILL.md 编写: 简洁步骤 + 命令 + 坑点提示(非对话稿)。
|
||||
- 命名规范: kebab-case(小写字母/数字/连字符,≤64 字符)。
|
||||
- 触发描述: 以 "When" 开头的单句(≤1024 字符、无尖括号)。
|
||||
- 结构校验: frontmatter 允许键、name 一致性、首尾分隔线。
|
||||
|
||||
3. 工具使用与错误处理
|
||||
- skill_list 优先调用,识别功能重叠与现有覆盖范围。
|
||||
- skill_manage 正确选 action=create / patch,并传递合规内容。
|
||||
- 报错诊断与修正后才能重试,禁止内容不变重提。
|
||||
|
||||
4. 质量控制与安全
|
||||
- 去重策略: 少而精,避免噪声与重复。
|
||||
- 隐私合规: 严禁嵌入用户特定数据、文件路径、凭据。
|
||||
- 一致性: name / description / content 三者语义对齐。
|
||||
|
||||
## Rules
|
||||
|
||||
### 0. 默认决策
|
||||
|
||||
**默认就是 do-nothing。** 仅当下面"必要条件"全部满足时才考虑创建;任一不满足 → do-nothing。垃圾技能比少一个技能更糟糕。
|
||||
|
||||
### 1. 创建必要条件(必须全部满足,AND 关系)
|
||||
|
||||
要创建一个新技能,以下条件**必须同时**满足:
|
||||
|
||||
1. **多步且有依赖**: 对话中至少 5 次工具调用,且步骤间存在依赖关系(不是并行查询,也不是同一工具的简单循环)。
|
||||
2. **包含纠错经验**: 包含至少一次失败 / 报错 / 意外结果,agent 通过修正后达到目标。这种"踩坑—纠错"才是有沉淀价值的知识。
|
||||
3. **跨场景可复用**: 抽离掉对话中的具体数据后,步骤本身在不同输入 / 不同数据集下仍然成立。
|
||||
4. **不与现有技能重叠**: skill_list 中没有功能覆盖率 ≥70% 的现有技能(若有,应当 patch 而非新建)。
|
||||
5. **能写出有料的 SKILL.md**: 抽象后能产出 ≥300 字、包含具体步骤 / 命令 / 坑点的工作流指南。写不出说明本来就没料。
|
||||
|
||||
### 2. 明确不要创建(即使工具调用很多也不要建)
|
||||
|
||||
- **单工具循环**: 连续多次 web_search 都只是搜索,没有形成工作流。
|
||||
- **一次性数据查询**: 仅是"搜索 + 总结"模式,没有可复用结构。
|
||||
- **LLM 常识可答**: 如"如何配置 Python 虚拟环境"这类公开文档 / 通识问题。
|
||||
- **流程过度通用**: 如"先搜索再总结再回答"这种描述等于没说。
|
||||
- **场景过窄**: 只在某一特定数据集 / 系统 / 项目下有效,换环境就失效。
|
||||
- **内容字数不足**: 写不出 ≥300 字含真干货的 SKILL.md。
|
||||
- **仅为"做点什么"而建**: 找不到清晰理由就 do-nothing,不要为了"完成 review"而强行创建。
|
||||
|
||||
### 3. 创建前自检(4 题,全部"是"才能 create)
|
||||
|
||||
提交 create 前在心中依次回答以下问题,任一为"否" → 立即放弃创建、改 do-nothing:
|
||||
|
||||
1. 如果我不创建这个技能,下一个遇到类似任务的用户会损失什么?(答得上来才证明有沉淀价值)
|
||||
2. 这个技能是否能被一句话简短复述?若"能",说明信息量不足 → do-nothing。
|
||||
3. SKILL.md 写完后是否包含至少 3 个具体步骤或命令?
|
||||
4. 步骤中是否含有非显而易见的坑点提示(不是 LLM 凭常识就能猜到的)?
|
||||
|
||||
### 4. 单次 review 上限
|
||||
|
||||
**每次 review 最多创建 1 条新技能。** 即便发现多个候选,也只挑最有价值的那一个,其余 do-nothing。这是硬约束,不接受例外。
|
||||
|
||||
### 5. Patch 优先于 Create
|
||||
|
||||
若 skill_list 中已存在功能相近(即便不完全相同)的技能,优先选择 patch 现有技能补充改进点,而非新建。新技能名额只留给**全新类别**的工作流。
|
||||
|
||||
### 6. 格式与命名硬约束
|
||||
|
||||
- 命名:kebab-case,仅含小写字母 / 数字 / 连字符,≤64 字符(如 git-bisect-bug-hunt、api-load-test)。
|
||||
- description:以 "When " 开头,单句,≤1024 字符,禁止尖括号 < >。
|
||||
- content:合法 SKILL.md,包含 frontmatter 与正文。
|
||||
- frontmatter 仅允许键:name、description、license、allowed-tools、metadata、compatibility、version、author。
|
||||
- frontmatter 首行与末行必须为 "---" 独占一行。
|
||||
- frontmatter 的 name 字段必须与 skill_manage 调用的 name 参数**完全一致**;不一致直接调用失败。
|
||||
- 禁止添加 tags、category、type 等额外键。
|
||||
- content 必须是简洁工作流指南,禁止对话转录。
|
||||
- 严禁包含用户特定数据、文件路径、凭据。
|
||||
|
||||
### 7. 错误处理
|
||||
|
||||
- 调用 skill_manage 报错 → 阅读错误信息,定位根因,**修正内容后**再重试。
|
||||
- 同名已存在 → 改用 action=patch(直接 create 同名会失败)。
|
||||
- 禁止在未修改内容的情况下重复提交。
|
||||
|
||||
## Workflows
|
||||
|
||||
### 目标
|
||||
仅在发现**高价值、可复用、有踩坑沉淀**的工作流时,创建或修补一条技能;其余一律 do-nothing。
|
||||
|
||||
### 步骤 1: 获取基线
|
||||
调用 skill_list,记录现有技能名称与功能覆盖范围。这一步在每次 review 中必须执行。
|
||||
|
||||
### 步骤 2: 价值初筛(三关,任一不过即 do-nothing 结束)
|
||||
通读对话并依次确认:
|
||||
- 工具调用总数是否 ≥5?否则 do-nothing 结束。
|
||||
- 是否包含失败 / 报错 / 修正过程?否则 do-nothing 结束。
|
||||
- 步骤间是否存在依赖关系?否则 do-nothing 结束。
|
||||
|
||||
只有三关全过才进入步骤 3。
|
||||
|
||||
### 步骤 3: 候选抽象
|
||||
将对话抽象为候选技能要素:
|
||||
- 候选名称(kebab-case)
|
||||
- 触发描述(When ... )
|
||||
- 关键步骤、命令、坑点提示
|
||||
- 剔除所有数据特定内容与隐私信息
|
||||
|
||||
### 步骤 4: 重叠检查
|
||||
与 skill_list 比对:
|
||||
- 完全无重叠 → 候选 action=create,进入步骤 5。
|
||||
- 部分重叠(功能覆盖 ≥70%)→ 候选 action=patch,跳到步骤 6。
|
||||
- 完全覆盖 → do-nothing 结束。
|
||||
|
||||
### 步骤 5: 创建前自检
|
||||
若候选为 create,依次回答 Rules 第 3 节的 4 个自检问题。任一为"否" → 立即放弃创建,do-nothing 结束。全部为"是"才进入步骤 6。
|
||||
|
||||
### 步骤 6: 提交
|
||||
- create:调用 skill_manage(action="create", name, content)。
|
||||
- patch:调用 skill_manage(action="patch", name, content),仅针对差异 / 改进点。
|
||||
- 一次 review 最多一次写操作。
|
||||
|
||||
### 步骤 7: 错误处理
|
||||
若报错,按 Rules 第 7 节处理:阅读错误信息 → 修正根因 → 再试。禁止内容不变重提。
|
||||
|
||||
### 预期结果
|
||||
- **多数情况下**: do-nothing。这是好的结果,不是失败。
|
||||
- **少数情况下**: 一条高价值新技能,或对现有技能的一次有针对性 patch。
|
||||
- **永远不要**: 为了"做点什么"而创建低价值技能。
|
||||
|
||||
## Initialization
|
||||
作为技能评审代理(保守模式),你必须严格遵守 Rules,按 Workflows 执行。**默认决策是 do-nothing。** 只有强证据完全满足所有创建条件时,才考虑创建。创建一条垃圾技能比放弃一条普通技能更糟糕。
|
||||
""")
|
||||
|
||||
|
||||
async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig", user_id: str = "default") -> None:
|
||||
"""Core review logic: create_agent with full middleware chain, no 'silent creation' constraint.
|
||||
|
||||
Uses create_agent (the deerflow agent factory) directly so we can:
|
||||
- Reuse the full middleware chain (ToolErrorHandlingMiddleware, etc.)
|
||||
- Reuse the full tool set
|
||||
- But pass app_config with skill_evolution.enabled=False to strip the
|
||||
"Never create skills silently without user confirmation" constraint
|
||||
from the system prompt.
|
||||
Review instructions are injected via PromptPrefixMiddleware through
|
||||
additional_kwargs["prompt_prefix"] on the HumanMessage.
|
||||
"""
|
||||
from langchain.agents import create_agent
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from deerflow.agents.lead_agent.prompt import apply_prompt_template
|
||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
|
||||
evolution_cfg = app_config.skill_evolution
|
||||
timeout = evolution_cfg.review_worker.timeout_seconds
|
||||
max_messages = evolution_cfg.review_worker.max_messages
|
||||
model_name = evolution_cfg.review_worker.model_name
|
||||
|
||||
conversation_text = _format_messages_for_review(messages, max_messages)
|
||||
|
||||
# Disable skill_evolution so the system prompt does NOT include
|
||||
# "Never create skills silently without user confirmation".
|
||||
# Also prevents SkillReviewMiddleware from registering (no recursive reviews).
|
||||
review_app_config = app_config.model_copy(deep=True)
|
||||
review_app_config.skill_evolution.enabled = False
|
||||
|
||||
try:
|
||||
model = create_chat_model(name=model_name, app_config=review_app_config)
|
||||
except Exception:
|
||||
logger.warning("skill_review_worker: could not load model %r, aborting review", model_name)
|
||||
return
|
||||
|
||||
tools = get_available_tools(app_config=review_app_config, subagent_enabled=False)
|
||||
middlewares = build_lead_runtime_middlewares(app_config=review_app_config)
|
||||
middlewares.append(PromptPrefixMiddleware())
|
||||
|
||||
system_prompt = apply_prompt_template(
|
||||
subagent_enabled=False,
|
||||
max_concurrent_subagents=0,
|
||||
app_config=review_app_config,
|
||||
memory_injection_enabled=False,
|
||||
)
|
||||
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=tools,
|
||||
middleware=middlewares,
|
||||
system_prompt=system_prompt,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
|
||||
# RunnableConfig supplies thread_id so ThreadDataMiddleware is satisfied.
|
||||
import uuid
|
||||
run_config = RunnableConfig(configurable={"thread_id": f"__skill-review__{uuid.uuid4()}"})
|
||||
|
||||
# Review instructions arrive as prompt_prefix: PromptPrefixMiddleware prepends
|
||||
# them before every model call without persisting them to state.
|
||||
first_message = HumanMessage(
|
||||
content=(
|
||||
"Review this conversation and apply any skill changes you deem appropriate.\n\n"
|
||||
f"<conversation>\n{conversation_text}\n</conversation>"
|
||||
),
|
||||
additional_kwargs={"prompt_prefix": _REVIEW_SYSTEM_PROMPT},
|
||||
)
|
||||
|
||||
def _invoke_with_user_context() -> dict:
|
||||
from deerflow.runtime.user_context import set_current_user, reset_current_user
|
||||
|
||||
class _SimpleUser:
|
||||
def __init__(self, uid: str) -> None:
|
||||
self.id = uid
|
||||
|
||||
token = set_current_user(_SimpleUser(user_id))
|
||||
try:
|
||||
return agent.invoke({"messages": [first_message]}, config=run_config)
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.get_event_loop().run_in_executor(None, _invoke_with_user_context),
|
||||
timeout=timeout,
|
||||
)
|
||||
# Log the final AI message so we can see what the review agent decided.
|
||||
final_msgs = (result or {}).get("messages", [])
|
||||
final_text = ""
|
||||
for msg in reversed(final_msgs):
|
||||
content = getattr(msg, "content", None)
|
||||
if content and isinstance(content, str) and content.strip():
|
||||
final_text = content.strip()[:1000]
|
||||
break
|
||||
logger.info(
|
||||
"skill_review_worker: review completed for thread %s — agent said: %s",
|
||||
thread_id,
|
||||
final_text or "(no text response)",
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("skill_review_worker: review timed out after %ds (thread %s)", timeout, thread_id)
|
||||
except Exception:
|
||||
logger.exception("skill_review_worker: review failed for thread %s", thread_id)
|
||||
|
||||
|
||||
async def _run_review_with_pending(
|
||||
thread_id: str,
|
||||
messages: list[BaseMessage],
|
||||
app_config: "AppConfig",
|
||||
user_id: str = "default",
|
||||
) -> None:
|
||||
"""Run one review; if a newer snapshot arrived while we ran, drain it."""
|
||||
lock = _get_lock(thread_id)
|
||||
async with lock:
|
||||
await _run_review(thread_id, messages, app_config, user_id)
|
||||
|
||||
pending = _pending.pop(thread_id, None)
|
||||
if pending is not None:
|
||||
asyncio.create_task(_run_review_with_pending(thread_id, pending, app_config, user_id))
|
||||
|
||||
|
||||
def maybe_start_review(
|
||||
thread_id: str,
|
||||
messages: list[BaseMessage],
|
||||
app_config: "AppConfig",
|
||||
user_id: str = "default",
|
||||
) -> bool:
|
||||
"""Schedule a background review for ``thread_id``.
|
||||
|
||||
Returns True if a new task was started, False if queued or skipped.
|
||||
"""
|
||||
evolution_cfg = app_config.skill_evolution
|
||||
if not evolution_cfg.enabled or not evolution_cfg.review_worker.enabled:
|
||||
return False
|
||||
|
||||
tool_count = extract_tool_call_count(messages)
|
||||
if tool_count < evolution_cfg.review_worker.min_tool_calls:
|
||||
logger.debug(
|
||||
"skill_review_worker: skipping review for thread %s (%d tool calls < min %d)",
|
||||
thread_id,
|
||||
tool_count,
|
||||
evolution_cfg.review_worker.min_tool_calls,
|
||||
)
|
||||
return False
|
||||
|
||||
lock = _get_lock(thread_id)
|
||||
if lock.locked():
|
||||
_pending[thread_id] = messages
|
||||
logger.debug("skill_review_worker: queued review for thread %s (review in progress)", thread_id)
|
||||
return False
|
||||
|
||||
asyncio.create_task(_run_review_with_pending(thread_id, messages, app_config, user_id))
|
||||
logger.info("skill_review_worker: started review for thread %s (%d tool calls) user=%s", thread_id, tool_count, user_id)
|
||||
return True
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""笔记本 RAG 检索工具。
|
||||
"""空间 RAG 检索工具。
|
||||
|
||||
从 LangGraph configurable 中读取 notebook_search_space_id / notebook_source_ids /
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在笔记本模式下引用回答。
|
||||
notebook_token,调用外部检索接口返回相关文本块,供 LLM 在空间模式下引用回答。
|
||||
|
||||
API base URL 通过环境变量 DEER_FLOW_NOTEBOOK_API_BASE 配置。
|
||||
"""
|
||||
|
||||
+65
-20
@@ -134,27 +134,58 @@ def _default_agent_store() -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _assert_agent_may_delete(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
||||
"""Pre-flight ownership check before agent-delete touches the on-disk file.
|
||||
async def _assert_skill_is_agent_authored(name: str) -> None:
|
||||
"""Refuse to modify skills authored by a human (UI upload / adopt / legacy).
|
||||
|
||||
LocalSkillStorage doesn't know about users — it deletes anything that
|
||||
matches by name. Without this guard, a malicious or hallucinating agent
|
||||
could destroy another user's skill just by knowing the name. We refuse
|
||||
unless the DB confirms ``user_id`` owns the skill outright.
|
||||
Even when ownership lines up, we draw a hard line at content the *human*
|
||||
curated through the Web UI: uploads, adopted orphan skills, and legacy
|
||||
skills with no usage entry (which default to ``source='upload'``).
|
||||
Those represent reviewed content the user has explicitly endorsed —
|
||||
letting the agent rewrite them risks silent drift away from what was
|
||||
approved. Only skills marked ``source='agent'`` (set by
|
||||
``mark_agent_created`` in the ``create`` flow) remain editable so the
|
||||
evolution loop can keep refining them.
|
||||
|
||||
Returns the DB record for use in the subsequent cascade step. Raises
|
||||
``ValueError`` (mapped to a tool error visible to the LLM) on refusal.
|
||||
Raises ``ValueError`` (mapped to a tool error visible to the LLM) on
|
||||
refusal.
|
||||
"""
|
||||
from deerflow.skills.usage import get_entry
|
||||
|
||||
entry = await _to_thread(get_entry, name)
|
||||
if entry.get("source") != "agent":
|
||||
raise ValueError(
|
||||
f"Refused: skill '{name}' was authored by a human via the Web UI "
|
||||
f"and cannot be modified by an agent. Ask the owner to edit it "
|
||||
f"through the Web UI, or have the user duplicate it to a new "
|
||||
f"agent-owned skill first."
|
||||
)
|
||||
|
||||
|
||||
async def _assert_agent_may_modify(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
|
||||
"""Pre-flight ownership check before an agent-driven write touches a custom skill.
|
||||
|
||||
LocalSkillStorage operates by name only — patch/edit/write_file/remove_file
|
||||
and delete all resolve a name to a directory without knowing who owns it.
|
||||
Without this guard a malicious or hallucinating agent could overwrite or
|
||||
destroy another user's skill (including UI-uploaded skills) just by
|
||||
knowing the name. We refuse unless the DB confirms ``user_id`` owns the
|
||||
skill outright.
|
||||
|
||||
Returns the DB record so callers that need owner info (e.g. delete
|
||||
cascade) can reuse it. Raises ``ValueError`` (mapped to a tool error
|
||||
visible to the LLM) on refusal.
|
||||
"""
|
||||
record = await store.get_any(name)
|
||||
if record is None:
|
||||
# No DB row — could be a legacy/orphan skill. Refuse rather than
|
||||
# delete blindly; admin can clean up via the Web UI.
|
||||
# touch blindly; users should adopt it via /api/skills/reconcile-custom
|
||||
# or an admin can clean it up via the Web UI.
|
||||
raise ValueError(
|
||||
f"Refused: skill '{name}' has no ownership record. Ask a human to delete it via the Web UI."
|
||||
f"Refused: skill '{name}' has no ownership record. Ask a human to adopt or manage it via the Web UI."
|
||||
)
|
||||
owner = record.get("owner_user_id")
|
||||
if owner is None:
|
||||
raise ValueError(f"Refused: skill '{name}' is built-in and cannot be deleted by an agent.")
|
||||
raise ValueError(f"Refused: skill '{name}' is built-in and cannot be modified by an agent.")
|
||||
if owner != user_id:
|
||||
raise ValueError(f"Refused: skill '{name}' is owned by another user.")
|
||||
return record
|
||||
@@ -200,6 +231,10 @@ async def _skill_manage_impl(
|
||||
lock = _get_lock(name)
|
||||
thread_id = _get_thread_id(runtime)
|
||||
skill_storage = get_or_new_skill_storage()
|
||||
# Resolved up-front so ownership checks in the write-action branches stay
|
||||
# one-liners. Cheap when the DB is unconfigured (returns None).
|
||||
user_id = get_effective_user_id()
|
||||
skill_store = _default_skill_store()
|
||||
|
||||
async with lock:
|
||||
if action == "create":
|
||||
@@ -224,14 +259,16 @@ async def _skill_manage_impl(
|
||||
# surfaces it (custom skills without a DB row are filtered out by
|
||||
# ``GET /api/skills``). Resolve the user_id *here* on the main
|
||||
# async stack — contextvars don't propagate into ``to_thread``.
|
||||
store = _default_skill_store()
|
||||
if store is not None:
|
||||
await _ensure_skill_db_registered(store, name, get_effective_user_id())
|
||||
if skill_store is not None:
|
||||
await _ensure_skill_db_registered(skill_store, name, user_id)
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
return f"Created custom skill '{name}'."
|
||||
|
||||
if action == "edit":
|
||||
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
|
||||
if skill_store is not None:
|
||||
await _assert_agent_may_modify(skill_store, name, user_id)
|
||||
await _assert_skill_is_agent_authored(name)
|
||||
if content is None:
|
||||
raise ValueError("content is required for edit.")
|
||||
await _to_thread(skill_storage.validate_skill_markdown_content, name, content)
|
||||
@@ -250,6 +287,9 @@ async def _skill_manage_impl(
|
||||
|
||||
if action == "patch":
|
||||
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
|
||||
if skill_store is not None:
|
||||
await _assert_agent_may_modify(skill_store, name, user_id)
|
||||
await _assert_skill_is_agent_authored(name)
|
||||
if find is None or replace is None:
|
||||
raise ValueError("find and replace are required for patch.")
|
||||
skill_file = skill_storage.get_custom_skill_file(name)
|
||||
@@ -277,11 +317,10 @@ async def _skill_manage_impl(
|
||||
# Ownership pre-check: LocalSkillStorage deletes purely by name,
|
||||
# so without this an agent could blow away another user's skill
|
||||
# just by knowing the name. Raise BEFORE touching the filesystem.
|
||||
user_id = get_effective_user_id()
|
||||
store = _default_skill_store()
|
||||
record: dict[str, Any] | None = None
|
||||
if store is not None:
|
||||
record = await _assert_agent_may_delete(store, name, user_id)
|
||||
if skill_store is not None:
|
||||
record = await _assert_agent_may_modify(skill_store, name, user_id)
|
||||
await _assert_skill_is_agent_authored(name)
|
||||
|
||||
await _to_thread(
|
||||
skill_storage.delete_custom_skill,
|
||||
@@ -306,9 +345,9 @@ async def _skill_manage_impl(
|
||||
await _to_thread(forget, name)
|
||||
except Exception:
|
||||
logger.debug("Failed to forget usage entry for %r", name, exc_info=True)
|
||||
if store is not None:
|
||||
if skill_store is not None:
|
||||
try:
|
||||
await store.delete(name, user_id)
|
||||
await skill_store.delete(name, user_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to delete DB row for %r", name, exc_info=True)
|
||||
agent_store = _default_agent_store()
|
||||
@@ -330,6 +369,9 @@ async def _skill_manage_impl(
|
||||
|
||||
if action == "write_file":
|
||||
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
|
||||
if skill_store is not None:
|
||||
await _assert_agent_may_modify(skill_store, name, user_id)
|
||||
await _assert_skill_is_agent_authored(name)
|
||||
if path is None or content is None:
|
||||
raise ValueError("path and content are required for write_file.")
|
||||
target = await _to_thread(skill_storage.ensure_safe_support_path, name, path)
|
||||
@@ -347,6 +389,9 @@ async def _skill_manage_impl(
|
||||
|
||||
if action == "remove_file":
|
||||
await _to_thread(skill_storage.ensure_custom_skill_is_editable, name)
|
||||
if skill_store is not None:
|
||||
await _assert_agent_may_modify(skill_store, name, user_id)
|
||||
await _assert_skill_is_agent_authored(name)
|
||||
if path is None:
|
||||
raise ValueError("path is required for remove_file.")
|
||||
target = await _to_thread(skill_storage.ensure_safe_support_path, name, path)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tests that the curator scheduler does not block application startup.
|
||||
|
||||
Verifies:
|
||||
1. _curator_scheduler_loop is launched as a non-blocking asyncio task.
|
||||
2. The scheduler sleeps for the startup delay before doing any work.
|
||||
3. maybe_run_curator is NOT called during the startup delay window.
|
||||
4. When the lock is unavailable the loop exits immediately without calling the curator.
|
||||
|
||||
NOTE: ``import app.gateway.app as m`` resolves to the FastAPI *instance* because
|
||||
``app/gateway/__init__.py`` re-exports ``app = FastAPI(...)``. The actual module
|
||||
object lives in ``sys.modules["app.gateway.app"]`` and is used here via a helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _gateway_module():
|
||||
"""Return the real app.gateway.app *module* (not the FastAPI instance)."""
|
||||
import importlib
|
||||
importlib.import_module("app.gateway.app") # side-effect: populates sys.modules
|
||||
return sys.modules["app.gateway.app"]
|
||||
|
||||
|
||||
_STARTUP_DELAY = 300 # matches _CURATOR_STARTUP_DELAY_SECONDS in app.gateway.app
|
||||
_CHECK_INTERVAL = 3600 # matches _CURATOR_CHECK_INTERVAL_SECONDS
|
||||
|
||||
|
||||
def test_scheduler_does_not_block_startup():
|
||||
"""create_task must return immediately — startup must not await the loop body."""
|
||||
mod = _gateway_module()
|
||||
curator_called = False
|
||||
|
||||
async def fake_sleep(_delay):
|
||||
pass
|
||||
|
||||
async def fake_curator():
|
||||
nonlocal curator_called
|
||||
curator_called = True
|
||||
|
||||
async def run():
|
||||
with (
|
||||
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
||||
patch.object(mod, "_release_singleton_lock"),
|
||||
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
||||
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
||||
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
||||
patch("asyncio.sleep", fake_sleep),
|
||||
):
|
||||
task = asyncio.create_task(mod._curator_scheduler_loop())
|
||||
# create_task must return immediately; the coroutine is still pending
|
||||
assert not task.done(), "task should be running in the background, not finished"
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(run())
|
||||
assert not curator_called, "curator must not fire before the startup delay completes"
|
||||
|
||||
|
||||
def test_scheduler_sleeps_startup_delay_before_first_curator_call():
|
||||
"""The very first asyncio.sleep must use the startup-delay value (300 s)."""
|
||||
mod = _gateway_module()
|
||||
sleep_calls: list[float] = []
|
||||
curator_called = False
|
||||
|
||||
async def fake_sleep(delay):
|
||||
sleep_calls.append(delay)
|
||||
raise asyncio.CancelledError # stop after recording first sleep
|
||||
|
||||
async def fake_curator():
|
||||
nonlocal curator_called
|
||||
curator_called = True
|
||||
|
||||
async def run():
|
||||
with (
|
||||
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
||||
patch.object(mod, "_release_singleton_lock"),
|
||||
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
||||
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
||||
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
||||
patch("asyncio.sleep", fake_sleep),
|
||||
):
|
||||
task = asyncio.create_task(mod._curator_scheduler_loop())
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert sleep_calls, "asyncio.sleep was never called"
|
||||
assert sleep_calls[0] == _STARTUP_DELAY, (
|
||||
f"first sleep must be the startup delay ({_STARTUP_DELAY}s), got {sleep_calls[0]}"
|
||||
)
|
||||
assert not curator_called, "curator must not be called before startup delay elapses"
|
||||
|
||||
|
||||
def test_scheduler_actually_calls_curator_after_startup_delay():
|
||||
"""After the startup delay elapses, maybe_run_curator must be called."""
|
||||
mod = _gateway_module()
|
||||
curator_call_count = 0
|
||||
|
||||
async def fake_sleep(*_):
|
||||
pass # instant — simulate time passing
|
||||
|
||||
async def fake_curator():
|
||||
nonlocal curator_call_count
|
||||
curator_call_count += 1
|
||||
# After first call cancel the loop so the test terminates
|
||||
raise asyncio.CancelledError
|
||||
|
||||
async def run():
|
||||
with (
|
||||
patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()),
|
||||
patch.object(mod, "_release_singleton_lock"),
|
||||
patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY),
|
||||
patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL),
|
||||
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
||||
patch("asyncio.sleep", fake_sleep),
|
||||
):
|
||||
task = asyncio.create_task(mod._curator_scheduler_loop())
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(run())
|
||||
assert curator_call_count >= 1, (
|
||||
f"maybe_run_curator should have been called at least once, got {curator_call_count}"
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_skips_when_lock_unavailable():
|
||||
"""When another worker holds the lock the loop must exit without doing any work."""
|
||||
mod = _gateway_module()
|
||||
curator_called = False
|
||||
|
||||
async def fake_curator():
|
||||
nonlocal curator_called
|
||||
curator_called = True
|
||||
|
||||
async def run():
|
||||
with (
|
||||
patch.object(mod, "_acquire_singleton_lock", return_value=None),
|
||||
patch("deerflow.skills.curator.maybe_run_curator", fake_curator),
|
||||
):
|
||||
await mod._curator_scheduler_loop()
|
||||
|
||||
asyncio.run(run())
|
||||
assert not curator_called, "curator must not run when lock is not acquired"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for SkillReviewMiddleware."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware
|
||||
|
||||
|
||||
def _make_state(messages):
|
||||
state = MagicMock()
|
||||
state.get.side_effect = lambda key, default=None: messages if key == "messages" else default
|
||||
return state
|
||||
|
||||
|
||||
def _make_runtime(thread_id="thread-abc"):
|
||||
runtime = MagicMock()
|
||||
runtime.context = {"thread_id": thread_id} if thread_id else {}
|
||||
return runtime
|
||||
|
||||
|
||||
def test_after_agent_calls_maybe_start_review():
|
||||
"""When enabled and tool calls exist, maybe_start_review is called with correct thread_id."""
|
||||
middleware = SkillReviewMiddleware()
|
||||
|
||||
ai_msg = MagicMock(spec=AIMessage)
|
||||
tc = MagicMock()
|
||||
tc.name = "bash"
|
||||
ai_msg.tool_calls = [tc]
|
||||
|
||||
state = _make_state([HumanMessage(content="hi"), ai_msg])
|
||||
runtime = _make_runtime("thread-xyz")
|
||||
|
||||
app_cfg = MagicMock()
|
||||
with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg):
|
||||
with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review:
|
||||
middleware.after_agent(state, runtime)
|
||||
|
||||
mock_review.assert_called_once()
|
||||
call_args = mock_review.call_args
|
||||
assert call_args[0][0] == "thread-xyz" # thread_id
|
||||
assert call_args[0][2] is app_cfg # app_config
|
||||
|
||||
|
||||
def test_after_agent_no_thread_id_skips_review():
|
||||
"""When thread_id is missing, maybe_start_review is NOT called."""
|
||||
middleware = SkillReviewMiddleware()
|
||||
state = _make_state([HumanMessage(content="hi")])
|
||||
runtime = _make_runtime("")
|
||||
|
||||
app_cfg = MagicMock()
|
||||
with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg):
|
||||
with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review:
|
||||
middleware.after_agent(state, runtime)
|
||||
|
||||
mock_review.assert_not_called()
|
||||
|
||||
|
||||
def test_after_agent_exception_is_swallowed():
|
||||
"""Exceptions in after_agent must not propagate."""
|
||||
middleware = SkillReviewMiddleware()
|
||||
state = _make_state([HumanMessage(content="hi")])
|
||||
runtime = _make_runtime("thread-err")
|
||||
|
||||
with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", side_effect=RuntimeError("boom")):
|
||||
# Must not raise
|
||||
middleware.after_agent(state, runtime)
|
||||
|
||||
|
||||
def test_skill_review_middleware_in_chain_when_enabled():
|
||||
"""SkillReviewMiddleware appears in middleware chain before LoopDetectionMiddleware when enabled."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from deerflow.agents.lead_agent.agent import _build_middlewares
|
||||
from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware
|
||||
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": "test",
|
||||
"is_plan_mode": False,
|
||||
"is_bootstrap": False,
|
||||
"is_scheduled_run": False,
|
||||
}
|
||||
}
|
||||
|
||||
app_cfg = MagicMock()
|
||||
app_cfg.skill_evolution.enabled = True
|
||||
app_cfg.skill_evolution.review_worker.enabled = True
|
||||
app_cfg.token_usage.enabled = False
|
||||
app_cfg.tool_search.enabled = False
|
||||
app_cfg.skill_stop.enabled = False
|
||||
app_cfg.summarization.enabled = False
|
||||
app_cfg.memory = MagicMock()
|
||||
app_cfg.guardrails.enabled = False
|
||||
app_cfg.title = MagicMock()
|
||||
app_cfg.title.model_name = None
|
||||
|
||||
with patch("deerflow.agents.lead_agent.agent.get_app_config", return_value=app_cfg):
|
||||
with patch("deerflow.agents.lead_agent.agent.build_lead_runtime_middlewares", return_value=[]):
|
||||
with patch("deerflow.agents.lead_agent.agent._create_summarization_middleware", return_value=None):
|
||||
with patch("deerflow.agents.lead_agent.agent._create_todo_list_middleware", return_value=None):
|
||||
with patch("deerflow.models.vision.vision_enabled", return_value=False):
|
||||
middlewares = _build_middlewares(config, model_name=None, app_config=app_cfg)
|
||||
|
||||
type_names = [type(m).__name__ for m in middlewares]
|
||||
assert "SkillReviewMiddleware" in type_names, f"SkillReviewMiddleware not in chain: {type_names}"
|
||||
|
||||
review_idx = type_names.index("SkillReviewMiddleware")
|
||||
loop_idx = type_names.index("LoopDetectionMiddleware")
|
||||
assert review_idx < loop_idx, f"SkillReviewMiddleware ({review_idx}) must be before LoopDetectionMiddleware ({loop_idx})"
|
||||
@@ -0,0 +1,102 @@
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
|
||||
|
||||
def test_review_worker_config_defaults():
|
||||
cfg = SkillEvolutionConfig()
|
||||
assert cfg.review_worker.enabled is False
|
||||
assert cfg.review_worker.min_tool_calls == 5
|
||||
assert cfg.review_worker.timeout_seconds == 120
|
||||
assert cfg.review_worker.model_name is None
|
||||
assert cfg.review_worker.max_messages == 60
|
||||
|
||||
|
||||
def test_review_worker_config_from_dict():
|
||||
cfg = SkillEvolutionConfig.model_validate({
|
||||
"enabled": True,
|
||||
"review_worker": {"enabled": True, "min_tool_calls": 5, "model_name": "my-model"},
|
||||
})
|
||||
assert cfg.review_worker.enabled is True
|
||||
assert cfg.review_worker.min_tool_calls == 5
|
||||
assert cfg.review_worker.model_name == "my-model"
|
||||
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import asyncio
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
|
||||
def _make_ai_message(tool_calls_data: list[dict]):
|
||||
msg = MagicMock()
|
||||
msg.__class__.__name__ = "AIMessage"
|
||||
tcs = []
|
||||
for tc in tool_calls_data:
|
||||
m = MagicMock()
|
||||
m.name = tc["name"]
|
||||
m.args = tc.get("args", {})
|
||||
tcs.append(m)
|
||||
msg.tool_calls = tcs
|
||||
return msg
|
||||
|
||||
|
||||
def test_extract_tool_call_count_counts_all():
|
||||
from deerflow.skills.review_worker import extract_tool_call_count
|
||||
msgs = [
|
||||
HumanMessage(content="do something"),
|
||||
_make_ai_message([{"name": "bash"}, {"name": "read_file"}]),
|
||||
_make_ai_message([{"name": "write_file"}]),
|
||||
]
|
||||
assert extract_tool_call_count(msgs) == 3
|
||||
|
||||
|
||||
def test_extract_tool_call_count_empty():
|
||||
from deerflow.skills.review_worker import extract_tool_call_count
|
||||
assert extract_tool_call_count([]) == 0
|
||||
|
||||
|
||||
def test_maybe_start_review_disabled_returns_false():
|
||||
from deerflow.skills.review_worker import maybe_start_review
|
||||
cfg = MagicMock()
|
||||
cfg.skill_evolution.enabled = False
|
||||
cfg.skill_evolution.review_worker.enabled = True
|
||||
cfg.skill_evolution.review_worker.min_tool_calls = 2
|
||||
msgs = [_make_ai_message([{"name": "bash"}, {"name": "read_file"}, {"name": "write_file"}])]
|
||||
assert maybe_start_review("thread-1", msgs, cfg) is False
|
||||
|
||||
|
||||
def test_maybe_start_review_too_few_tool_calls_returns_false():
|
||||
from deerflow.skills.review_worker import maybe_start_review
|
||||
cfg = MagicMock()
|
||||
cfg.skill_evolution.enabled = True
|
||||
cfg.skill_evolution.review_worker.enabled = True
|
||||
cfg.skill_evolution.review_worker.min_tool_calls = 5
|
||||
msgs = [_make_ai_message([{"name": "bash"}])]
|
||||
assert maybe_start_review("thread-4", msgs, cfg) is False
|
||||
|
||||
|
||||
def test_maybe_start_review_starts_task():
|
||||
from deerflow.skills import review_worker as rw
|
||||
# Reset module state so thread-3 has no lock
|
||||
rw._locks.pop("thread-3", None)
|
||||
rw._pending.pop("thread-3", None)
|
||||
|
||||
cfg = MagicMock()
|
||||
cfg.skill_evolution.enabled = True
|
||||
cfg.skill_evolution.review_worker.enabled = True
|
||||
cfg.skill_evolution.review_worker.min_tool_calls = 2
|
||||
cfg.skill_evolution.review_worker.timeout_seconds = 30
|
||||
cfg.skill_evolution.review_worker.max_messages = 20
|
||||
cfg.skill_evolution.review_worker.model_name = None
|
||||
|
||||
msgs = [_make_ai_message([{"name": "bash"}, {"name": "read_file"}, {"name": "write_file"}])]
|
||||
|
||||
with patch("deerflow.skills.review_worker.asyncio") as mock_asyncio:
|
||||
fake_lock = MagicMock()
|
||||
fake_lock.locked.return_value = False
|
||||
mock_asyncio.Lock.return_value = fake_lock
|
||||
mock_asyncio.create_task = MagicMock()
|
||||
|
||||
result = rw.maybe_start_review("thread-3", msgs, cfg)
|
||||
|
||||
assert result is True
|
||||
mock_asyncio.create_task.assert_called_once()
|
||||
@@ -1,10 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"moderation_model_name": null,
|
||||
"curator": {
|
||||
"enabled": false,
|
||||
"interval_hours": 168,
|
||||
"enabled": true,
|
||||
"interval_hours": 24,
|
||||
"stale_after_days": 30,
|
||||
"archive_after_days": 90
|
||||
},
|
||||
"review_worker": {
|
||||
"enabled": true,
|
||||
"min_tool_calls": 3,
|
||||
"max_messages": 60,
|
||||
"timeout_seconds": 120,
|
||||
"model_name": null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user