Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-06-11 16:39:30 +08:00
commit 5daa4b3e20
62 files changed
+4230 -1025

No files matched your search

@@ -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"
/>
+1 -1
View File
@@ -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(/\/+$/, "");
+8 -1
View File
@@ -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",
+2 -1
View File
@@ -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 });
+26 -7
View File
@@ -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}`);
+47 -19
View File
@@ -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,
};
}
+1 -3
View File
@@ -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>
);
}
+10
View File
@@ -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>
);
}
+2
View File
@@ -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
+15 -15
View File
@@ -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">
@@ -142,10 +142,24 @@ export interface IntentSummary {
*/
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
/**
* 用户上传文件的引用(已经过 `/api/threads/{thread_id}/uploads`)。透传给后端
* `additional_kwargs.files`,由 UploadsMiddleware 注入文件清单并解锁 read_file 等工具。
* 字段与普通聊天的 `FileInMessage` 对齐:`path` 用上传返回的 `virtual_path`。
*/
export interface IntentFileRef {
filename: string;
size: number;
path: string;
status?: "uploading" | "uploaded";
}
export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
/** Step 1 用户上传的参考文件(可选)。 */
files?: IntentFileRef[];
signal?: AbortSignal;
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
onTextDelta?: (text: string, messageId: string) => void;
@@ -318,6 +332,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId,
message: req.message,
model: req.model,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
signal: req.signal,
});
@@ -227,6 +227,12 @@ export interface StreamMultiAgentRequest {
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
*/
peerDeliveries?: Array<{ name: string; content: string }>;
/**
* 用户随干预消息上传的参考文件(已经过 `/api/threads/{thread_id}/uploads`)。
* 透传给后端 `additional_kwargs.files`,由 UploadsMiddleware 注入清单 + 解锁
* read_file 等工具。字段 `path` 用上传返回的 `virtual_path`。
*/
files?: Array<{ filename: string; size: number; path: string; status?: string }>;
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
@@ -447,6 +453,8 @@ export async function streamMultiAgent(
parallel_no_file: req.parallelNoFile ?? false,
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
peer_deliveries: req.peerDeliveries ?? [],
// 用户随干预上传的参考文件;仅在有文件时带上,后端注入 additional_kwargs.files。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
signal: req.signal,
});
@@ -14,8 +14,10 @@
import { useRef, useState } from "react";
import {
CheckCircle,
FileText,
Loader2,
MessageSquare,
Paperclip,
Pause,
Repeat,
Save,
@@ -25,6 +27,13 @@ import {
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import { Button } from "@/components/ui/button";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import {
@@ -62,7 +71,7 @@ export interface Step1PanelProps {
setIntentClarificationSelections: React.Dispatch<
React.SetStateAction<Map<string, IntentClarificationSelection>>
>;
onSendMessage: (text: string) => void;
onSendMessage: (text: string, files?: File[]) => void;
onStopIntent: () => void;
onSkipQuestion: () => void;
onFollowUp: () => void;
@@ -101,13 +110,34 @@ export function Step1Panel(props: Step1PanelProps) {
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
// 待发送的本地附件(点发送时连同文本一起上传)。
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const charactersLeft = inputText.length;
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
// 去重(按 名称+大小),追加到已选列表。
setAttachedFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
// 清空 input 值,允许再次选同名文件。
e.target.value = "";
};
const removeAttachedFile = (idx: number) => {
setAttachedFiles((prev) => prev.filter((_, i) => i !== idx));
};
const handleSend = () => {
if (!inputText.trim()) return;
onSendMessage(inputText);
if (!inputText.trim() && attachedFiles.length === 0) return;
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
setInputText("");
setAttachedFiles([]);
};
return (
@@ -150,6 +180,33 @@ export function Step1Panel(props: Step1PanelProps) {
{/* Bottom input area */}
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
{/* 已选附件预览(点发送时随消息上传) */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 pb-2 mb-2 border-b border-slate-100">
{attachedFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeAttachedFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<textarea
id="input-text-area"
value={inputText}
@@ -177,6 +234,21 @@ export function Step1Panel(props: Step1PanelProps) {
>
<Smile className="w-4 h-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickFiles}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="p-1 hover:text-primary hover:bg-slate-50 rounded transition"
>
<Paperclip className="w-4 h-4" />
</button>
</div>
<span className="text-[10px] font-mono text-slate-400">{charactersLeft}/500</span>
@@ -422,8 +494,30 @@ function Step1Message({
)}
</div>
) : (
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
<div className="space-y-2">
{msg.files && msg.files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{msg.files.map((file, i) => (
<span
key={`${file.filename}-${i}`}
className="inline-flex items-center gap-1.5 bg-white/70 border border-slate-200 rounded-lg px-2 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileText className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.filename}>
{file.filename}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
</span>
))}
</div>
)}
{msg.content && (
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
</div>
)}
</div>
)}
</MessageBubble>
@@ -23,11 +23,20 @@ import {
HelpCircle,
Loader2,
Network,
Paperclip,
Pause,
Play,
Sparkles,
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
import {
Dialog,
DialogContent,
@@ -124,7 +133,7 @@ export interface Step2PanelProps {
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onResumeRoundtable: () => void;
onHandleInjectToRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
@@ -251,11 +260,34 @@ export function Step2Panel(props: Step2PanelProps) {
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
const [pauseFlash, setPauseFlash] = useState(false);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const injectFileInputRef = useRef<HTMLInputElement>(null);
const handlePickInjectFiles = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
e.target.value = "";
},
[],
);
const removeInjectFile = useCallback((idx: number) => {
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
}, []);
const handleInject = useCallback(() => {
onHandleInjectToRoundtable();
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
setPauseFlash(true);
window.setTimeout(() => setPauseFlash(false), 1600);
}, [onHandleInjectToRoundtable]);
}, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming =
isOrchestrating ||
@@ -530,7 +562,35 @@ export function Step2Panel(props: Step2PanelProps) {
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex flex-col gap-2">
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
{injectFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{injectFiles.map((file, idx) => (
<span
key={`${file.name}-${file.size}-${idx}`}
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
>
<FileIcon className="w-3 h-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">
{formatFileSize(file.size)}
</span>
<button
type="button"
onClick={() => removeInjectFile(idx)}
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
title="移除"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-center gap-3">
<input
type="text"
value={customAgentInput}
@@ -542,6 +602,22 @@ export function Step2Panel(props: Step2PanelProps) {
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
/>
<input
ref={injectFileInputRef}
type="file"
multiple
className="hidden"
onChange={handlePickInjectFiles}
/>
<button
type="button"
onClick={() => injectFileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
>
<Paperclip className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleInject}
@@ -581,6 +657,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
</>
);
@@ -19,9 +19,11 @@ import {
initIntent,
streamIntent,
type IntentErrorDetail,
type IntentFileRef,
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import { uploadFiles } from "@/core/uploads/api";
import {
clarificationKey,
formatIntentClarificationAnswer,
@@ -42,6 +44,8 @@ export type InteractiveMessage = {
time: string;
streaming?: boolean;
phase?: string;
/** 用户气泡里附带的上传文件(仅展示用:文件名 + 大小)。 */
files?: Array<{ filename: string; size: number }>;
/** First clarification (back-compat); prefer `clarifications`. */
clarification?: ClarificationPayload;
/**
@@ -109,8 +113,12 @@ export interface UseStep1IntentReturn {
// ─── Handlers ────────────────────────────────────────────────────────
/** 清空当前 Step 1 状态并重新拉取 intent thread。 */
runStep1Init: () => void;
/** 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。 */
sendIntentMessage: (userText: string) => Promise<void>;
/**
* 把 `userText` 作为用户气泡发给 intent agent,流式渲染回复。
* `files`:可选的本地文件,发送前先上传到 intent thread,再把引用随消息提交(后端
* 注入 additional_kwargs.files,意图智能体即可 read_file 读取)。允许「仅文件无文本」。
*/
sendIntentMessage: (userText: string, files?: File[]) => Promise<void>;
/**
* 提交某条 agent 消息的全部澄清问题答案 —— 逐题取各自选择,拼成一条合并消息
* 发回。至少答一题才会发送。单题时退化为发送该题答案。
@@ -275,9 +283,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}, [interactiveMessages, intentReady]);
const sendIntentMessage = useCallback(
async (userText: string) => {
async (userText: string, files?: File[]) => {
const trimmed = userText.trim();
if (!trimmed) return;
const hasFiles = !!(files && files.length > 0);
if (!trimmed && !hasFiles) return;
if (!intentThreadId) {
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
return;
@@ -304,16 +313,24 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
senderName: "用户",
content: trimmed,
time: userMsgTime,
files: hasFiles
? files!.map((f) => ({ filename: f.name, size: f.size }))
: undefined,
};
setInteractiveMessages((prev) => [...prev, userMsg]);
const timelineDesc = trimmed
? trimmed.length > 25
? trimmed.substring(0, 25) + "..."
: trimmed
: `上传了 ${files!.length} 个文件`;
setTimelineEntries((prev) => [
...prev,
{
id: prev.length + 1,
time: userMsgTime,
title: "用户实时反馈",
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
desc: timelineDesc,
},
]);
@@ -333,10 +350,49 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setIsIntentSending(true);
try {
// 先把本地文件上传到 intent thread —— 与普通聊天同一上传接口/技能链路。
// 拿到 virtual_path 后随消息提交,后端注入 additional_kwargs.files。
let fileRefs: IntentFileRef[] | undefined;
if (hasFiles) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId ? { ...m, phase: "正在上传文件" } : m,
),
);
try {
const up = await uploadFiles(intentThreadId, files!);
fileRefs = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded" as const,
}));
} catch (err) {
setInteractiveMessages((prev) =>
prev.map((m) =>
m.id === agentMsgId
? {
...m,
content: `(文件上传失败) ${(err as Error).message}`,
streaming: false,
}
: m,
),
);
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
if (intentAborterRef.current === aborter) {
intentAborterRef.current = null;
}
setIsIntentSending(false);
return;
}
}
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
setInteractiveMessages((prev) =>
@@ -23,8 +23,20 @@ import {
type ClarificationPayload,
type LeaderDispatch,
type StreamPhase,
type StreamMultiAgentRequest,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
function takeOnce<T>(ref: { current: T | null }): T | undefined {
const v = ref.current ?? undefined;
ref.current = null;
return v;
}
import {
buildDagStageLeaderTask,
pickDagSeatTask,
@@ -305,8 +317,11 @@ export interface UseStep2OrchestrationReturn {
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
handleInjectToRoundtable: () => void;
/**
* 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。
* `files`:可选的参考文件,发送前先上传到总控 thread,首轮 leader run 据此 read_file 读取。
*/
handleInjectToRoundtable: (files?: File[]) => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
/** 预算滑块(本地演示,非编排真实输入)。 */
@@ -464,6 +479,11 @@ export function useStep2Orchestration(
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
const pendingInjectPromptRef = useRef<string | null>(null);
/**
* 干预消息携带的上传文件引用:由下一轮编排的**首个 leader run**消费一次后清空,
* 总控据此 read_file 读取文件再派活。consume-once 语义避免后续轮次重复带文件。
*/
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
@@ -1353,6 +1373,8 @@ export function useStep2Orchestration(
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1600,6 +1622,8 @@ export function useStep2Orchestration(
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -1887,6 +1911,8 @@ export function useStep2Orchestration(
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
suppressPreBroadcast: true,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
@@ -2289,38 +2315,75 @@ export function useStep2Orchestration(
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
const userText = customAgentInput.trim();
if (!userText) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
const handleInjectToRoundtable = useCallback(
async (files?: File[]) => {
const userText = customAgentInput.trim();
const hasFiles = !!(files && files.length > 0);
if (!userText && !hasFiles) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
// 先把文件上传到总控 thread(与普通聊天同一上传接口/技能链路)。拿到 virtual_path
// 存进 consume-once ref,供下一轮首个 leader run 注入 additional_kwargs.files。
// 上传失败则中止本次干预,不打断当前会商。
if (hasFiles) {
const coordThreadId = resolveSeatThreadId(
threadIdsRef.current,
coordinatorNameRef.current,
);
try {
const up = await uploadFiles(coordThreadId, files!);
pendingInjectFilesRef.current = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded",
}));
} catch (err) {
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
return;
}
}
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
const prompt = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
setCustomAgentInput("");
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
const shownText = userText || "(上传了参考文件)";
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${shownText}${fileNote}`,
confidence: "最高指令",
});
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
const promptText =
userText || "我上传了参考文件,请先阅读文件内容,再据此推进研讨。";
const prompt = `用户人工干预新指令:${promptText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
// 直接启动新一轮编排
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
},
[
customAgentInput,
appendDialogue,
abortAllInFlight,
runActiveOrchestration,
resolveSeatThreadId,
triggerToast,
],
);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -1010,7 +1010,7 @@ export default function RoundtablePlanningPage() {
pendingIntentClarificationMsgId={step1.pendingIntentClarificationMsgId}
intentClarificationSelections={step1.intentClarificationSelections}
setIntentClarificationSelections={step1.setIntentClarificationSelections}
onSendMessage={(text) => void step1.sendIntentMessage(text)}
onSendMessage={(text, files) => void step1.sendIntentMessage(text, files)}
onStopIntent={step1.stopIntentStream}
onSkipQuestion={handleSkipQuestion}
onFollowUp={handleAgentFollowUp}
@@ -1,12 +1,18 @@
import { TaskListItem } from './TaskManagement';
/**
* 任务看板假数据 —— 用于在没有后端 `/task/cop-tasks` / `/three/cop-three-tasks`
* 接口时,给「任务分析」「三情分析」两个页面提供演示数据。
* 任务看板假数据 —— 给「任务分析」「三情分析」两个页面提供演示数据,
* 用于没有后端 `/task/cop-tasks` / `/three/cop-three-tasks` 接口时本地预览。
*
* 用法:把组件里的 `await service.post(url, payload)` 换成
* `await mockCopTasks('task' | 'sentiment', taskType, payload)`
* 返回结构与真实接口保持一致:`{ data: { records, total } }`。
* 是否启用由环境变量 `VITE_USE_REAL_DATA` 控制(见 `utils/request.ts`):
* - `VITE_USE_REAL_DATA=true` → 走真实接口(不挂本 mock)
* - 其它(含未设置) → 这两个接口走本地假数据
* 提交的 .env.development / .env.production / .env.network / .env.sit 默认 true(真实),
* 仅本机 .env 默认 false(假数据)。
*
* 实现方式:作为一个临时 axios adapter 挂在请求拦截器上(命中两个 url 时直接返回 mock body,
* 不发真实请求),业务组件 `SentimentAnalyzes.tsx` / `TaskAnalyzes.tsx` 一行不用改。
* 返回结构与真实接口一致:`{ code, message, data: { records, total } }`。
*/
export type MockColumnType = 'unanalyzed' | 'running' | 'completed';
@@ -402,7 +408,7 @@ export function matchTaskBoardMock(url?: string): MockDataset | null {
/**
* 临时假数据 axios adapter:命中任务看板接口时直接返回 mock body,不发真实请求。
* 仅用于演示,撤掉假数据时删除 request.ts 中对它的引用即可。
* 仅在 `VITE_USE_REAL_DATA !== 'true'` 时由 request.ts 挂上(见那里的请求拦截器)。
*/
export function taskBoardMockAdapter(config: {
url?: string;
+9 -3
View File
@@ -1,6 +1,12 @@
import axios from 'axios'
// === 临时假数据(任务分析 / 三情分析看板):撤掉时删除本 import 及下方 request 拦截器中的对应块即可 ===
import { matchTaskBoardMock, taskBoardMockAdapter } from '@/strategy-components/components/task-components/task-mock-data'
// 任务看板(任务分析 / 三情分析)数据源开关:
// VITE_USE_REAL_DATA=true → 走真实接口;其它(含未设置)→ 这两个接口走本地假数据。
// 提交的 .env.development/.env.production/.env.network/.env.sit 默认 true(真实数据),
// 仅本机 .env 默认 false(假数据)。改 env 后需重启 dev server 生效。
const USE_REAL_DATA = import.meta.env.VITE_USE_REAL_DATA === 'true'
// axios.interceptors
const service = axios.create({
// baseURL: '', // url = base url + request url
@@ -33,8 +39,8 @@ service.interceptors.response.use(
)
service.interceptors.request.use(
config => {
// === 临时假数据:任务看板两个接口直接走本地 mock,不发真实请求 ===
if (matchTaskBoardMock(config.url)) {
// 假数据模式:任务看板两个接口直接走本地 mock,不发真实请求(VITE_USE_REAL_DATA=true 时跳过)。
if (!USE_REAL_DATA && matchTaskBoardMock(config.url)) {
config.adapter = taskBoardMockAdapter as any;
}
// let token: any = localStorage.getItem('token');