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; 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; 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(null); const [selectedSkill, setSelectedSkill] = useState(null); const [selectedSkills, setSelectedSkills] = useState([]); 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) 回滚 localStorage(latest + 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, }; }