deerflow2/frontend/src/hooks/use-iframe-skill.ts

209 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "sonner";
import {
POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES,
isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem,
sendToParent,
} from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型
interface SkillData {
skill_id: string;
title: string;
}
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
openSkillDialog: () => void;
clearSkill: () => void;
}
interface UseIframeSkillOptions {
threadId?: string | null;
}
export function useIframeSkill(
options?: UseIframeSkillOptions,
): UseIframeSkillReturn {
const router = useRouter();
const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id");
const threadId = options?.threadId?.trim() || threadIdFromQuery;
const isChattingFromQuery = searchParams.get("is_chatting");
const lastThreadIdRef = useRef<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [isBootstrapping, setIsBootstrapping] = useState(false);
// 1. 监听 query 参数变化(临时禁用)
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
// useEffect(() => {
// const skillIdFromQuery = searchParams.get("skill_id");
// const titleFromQuery = searchParams.get("title");
// if (skillIdFromQuery && titleFromQuery) {
// setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
// }
// }, [searchParams]);
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
// useEffect(() => {
// if (!threadId) return;
// if (isChattingFromQuery !== "true") return;
// if (lastThreadIdRef.current === threadId) return;
// lastThreadIdRef.current = threadId;
// router.replace(`/workspace/chats/${threadId}`);
// }, [isChattingFromQuery, router, threadId]);
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (isSelectedSkillMessage(event.data)) {
const { id, title } = event.data;
setSelectedSkill({ skill_id: String(id), title });
return;
}
if (isSelectedSkillsMessage(event.data)) {
const first = event.data.selectedSkills[0];
if (!first) {
setSelectedSkill(null);
return;
}
setSelectedSkill({ skill_id: String(first.id), title: first.name });
return;
}
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// 发送选择预定义 skill
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
console.log("[useIframeSkill] sendSelectSkill:", message);
sendToParent(message);
}, []);
const bootstrapAndLockSkills = useCallback(
async ({
selectedSkills,
title,
}: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => {
if (!threadId) {
toast.error("技能加载失败", {
description: "缺少 thread_id无法初始化技能",
});
return false;
}
const content_ids = Array.from(
new Set(
selectedSkills
.map((item) => Number(String(item.id).trim()))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (content_ids.length === 0) {
toast.error("技能加载失败", {
description: "无效的 skill_id",
});
return false;
}
const languageTypeRaw =
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "suggest-skill-bootstrap" });
try {
const result = await bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
toast.dismiss("suggest-skill-bootstrap");
if (!result.success) {
toast.error(`技能「${title}」加载失败`, {
description: result.message || "未知错误",
});
return false;
}
sendSelectSkill(selectedSkills);
setSelectedSkill({ skill_id: String(content_ids[0]), title });
toast.success(`技能「${title}」加载成功`, {
description: result.message || `已创建 ${result.created_files} 个文件`,
});
return true;
} catch (error) {
toast.dismiss("suggest-skill-bootstrap");
const message =
error instanceof Error ? error.message : "网络请求失败";
toast.error(`技能「${title}」加载失败`, {
description: message,
});
return false;
} finally {
setIsBootstrapping(false);
}
},
[searchParams, sendSelectSkill, threadId],
);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
const message = {
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
openSkillDialog: true,
} as const;
console.log("[useIframeSkill] openSkillDialog:", message);
sendToParent(message);
}, []);
// 清除选中并发送空 selectedSkills 数组给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送空数组给主页,通知取消选择
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
sendToParent(message);
}, []);
return {
selectedSkill,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,
openSkillDialog,
clearSkill,
};
}