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

411 lines
13 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;
}
const STORAGE_KEYS = {
latest: "iframe:selectedSkills:latest",
byThreadPrefix: "iframe:selectedSkills:thread:",
} as const;
function getThreadStorageKey(threadId?: string | null): string | null {
const normalized = threadId?.trim();
if (!normalized) return null;
return `${STORAGE_KEYS.byThreadPrefix}${normalized}`;
}
function parseStoredSkills(raw: string | null): SkillData[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed
.map((item) => {
if (typeof item !== "object" || item === null) return null;
const record = item as Record<string, unknown>;
const skillId = String(record.skill_id ?? "").trim();
const title = String(record.title ?? "").trim();
if (!skillId || !title) return null;
return { skill_id: skillId, title };
})
.filter((item): item is SkillData => item !== null);
} catch {
return [];
}
}
function removeSkillsByIdsFromList(
skills: SkillData[],
skillIds: string[],
): SkillData[] {
if (skillIds.length === 0) return skills;
const idSet = new Set(skillIds.map((id) => String(id)));
return skills.filter((skill) => !idSet.has(String(skill.skill_id)));
}
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
selectedSkills: SkillData[];
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
openSkillDialog: () => void;
clearSkill: (skillId?: string) => 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 [selectedSkills, setSelectedSkills] = useState<SkillData[]>([]);
const [isBootstrapping, setIsBootstrapping] = useState(false);
const removeFailedSkills = useCallback(
(skillIds: string[]) => {
if (skillIds.length === 0) return;
// 1) 回滚内存状态:移除失败 skill避免展示错误 tag
setSelectedSkills((prev) => {
const next = removeSkillsByIdsFromList(prev, skillIds);
setSelectedSkill(next[0] ?? null);
return next;
});
// 2) 回滚 localStoragelatest + thread
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeSkillsByIdsFromList(
latestSkills,
skillIds,
);
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
const threadSkills = parseStoredSkills(
window.localStorage.getItem(threadKey),
);
const nextThreadSkills = removeSkillsByIdsFromList(
threadSkills,
skillIds,
);
if (nextThreadSkills.length > 0) {
window.localStorage.setItem(
threadKey,
JSON.stringify(nextThreadSkills),
);
} else {
window.localStorage.removeItem(threadKey);
}
}
},
[threadId],
);
// 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;
const singleSkill = { skill_id: String(id), title };
setSelectedSkill(singleSkill);
setSelectedSkills([singleSkill]);
return;
}
if (isSelectedSkillsMessage(event.data)) {
const normalizedSkills = event.data.selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
if (normalizedSkills.length === 0) {
setSelectedSkill(null);
setSelectedSkills([]);
return;
}
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
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);
}, []);
// 3. 首次进入时恢复 localStorage 中上次选择的 skill线程优先其次全局
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
const threadSkills = threadKey
? parseStoredSkills(window.localStorage.getItem(threadKey))
: [];
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const restoredSkills =
threadSkills.length > 0 ? threadSkills : latestSkills;
if (restoredSkills.length === 0) return;
setSelectedSkills(restoredSkills);
setSelectedSkill(restoredSkills[0] ?? null);
}, [threadId]);
// 4. 选择变化时同步到 localStorage
useEffect(() => {
const threadKey = getThreadStorageKey(threadId);
if (selectedSkills.length === 0) {
// 空数组也要同步到存储,避免 UI 状态与缓存不一致
window.localStorage.removeItem(STORAGE_KEYS.latest);
if (threadKey) {
window.localStorage.removeItem(threadKey);
}
return;
}
const payload = JSON.stringify(selectedSkills);
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
if (threadKey) {
window.localStorage.setItem(threadKey, payload);
}
}, [selectedSkills, threadId]);
// 发送选择预定义 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) {
const failedIds = selectedSkills.map((item) =>
String(item.id).trim(),
);
removeFailedSkills(failedIds);
toast.error(`技能「${title}」加载失败`, {
description: result.message || "未知错误",
});
return false;
}
sendSelectSkill(selectedSkills);
const normalizedSkills = selectedSkills.map((item) => ({
skill_id: String(item.id),
title: item.name,
}));
setSelectedSkill(normalizedSkills[0] ?? null);
setSelectedSkills(normalizedSkills);
toast.success(`技能「${title}」加载成功`, {
description:
result.message || `已创建 ${result.created_files} 个文件`,
});
return true;
} catch (error) {
const failedIds = selectedSkills.map((item) => String(item.id).trim());
removeFailedSkills(failedIds);
toast.dismiss("suggest-skill-bootstrap");
const message = error instanceof Error ? error.message : "网络请求失败";
toast.error(`技能「${title}」加载失败`, {
description: message,
});
return false;
} finally {
setIsBootstrapping(false);
}
},
[removeFailedSkills, 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(
(skillId?: string) => {
const removeAll = !skillId;
const nextSelectedSkills = removeAll
? []
: selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
setSelectedSkills(nextSelectedSkills);
setSelectedSkill(nextSelectedSkills[0] ?? null);
// 同步 latest 缓存:仅删除对应 skill或全部清空
const latestSkills = parseStoredSkills(
window.localStorage.getItem(STORAGE_KEYS.latest),
);
const nextLatestSkills = removeAll
? []
: latestSkills.filter((skill) => skill.skill_id !== String(skillId));
if (nextLatestSkills.length > 0) {
window.localStorage.setItem(
STORAGE_KEYS.latest,
JSON.stringify(nextLatestSkills),
);
} else {
window.localStorage.removeItem(STORAGE_KEYS.latest);
}
// 同步线程缓存:保存剩余数组,空则删除 key
const threadKey = getThreadStorageKey(threadId);
if (threadKey) {
if (nextSelectedSkills.length > 0) {
window.localStorage.setItem(
threadKey,
JSON.stringify(nextSelectedSkills),
);
} else {
window.localStorage.removeItem(threadKey);
}
}
// 通知宿主页当前剩余技能
const message = {
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: nextSelectedSkills.map((skill) => ({
id: skill.skill_id,
name: skill.title,
})),
} as const;
console.log("[useIframeSkill] clearSkill:", message);
sendToParent(message);
},
[selectedSkills, threadId],
);
return {
selectedSkill,
selectedSkills,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,
openSkillDialog,
clearSkill,
};
}