411 lines
13 KiB
TypeScript
411 lines
13 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;
|
||
}
|
||
|
||
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) 回滚 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,
|
||
};
|
||
}
|