209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
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,
|
||
};
|
||
}
|