{
if (isBootstrapping) return;
- // 优先从 children 中提取 skill_id 数组,成功 bootstrap 后再同步到宿主页
- const childSkillIds = (suggestion.children ?? [])
- .map((item) => String(item.id).trim())
- .filter((id): id is string => Boolean(id));
- if (childSkillIds.length > 0) {
+ // 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
+ const childSkills = (suggestion.children ?? [])
+ .map((item) => ({
+ id: String(item.id).trim(),
+ name: item.name?.trim() ?? "",
+ }))
+ .filter((item): item is { id: string; name: string } =>
+ Boolean(item.id) && Boolean(item.name),
+ );
+ if (childSkills.length > 0) {
void bootstrapAndLockSkills({
- selectedSkills: childSkillIds.map((id) => ({
- id,
- name: suggestion.suggestion,
- })),
+ selectedSkills: childSkills,
title: suggestion.suggestion,
});
return;
@@ -637,13 +639,13 @@ function AddAttachmentsButton({ className }: { className?: string }) {
// 启动iframeSkillDialog
function IframeSkillDialogButton({
className,
- selectedSkill,
+ selectedSkills,
isBootstrapping,
openSkillDialog,
clearSkill,
}: {
className?: string;
- selectedSkill: { skill_id: string; title: string } | null;
+ selectedSkills: Array<{ skill_id: string; title: string }>;
isBootstrapping: boolean;
openSkillDialog: () => void;
clearSkill: () => void;
@@ -676,17 +678,21 @@ function IframeSkillDialogButton({
{t.common.loading}
) : null}
- {!isBootstrapping && selectedSkill ? (
-
- {selectedSkill.title}
-
-
+ {!isBootstrapping && selectedSkills.length > 0 ? (
+
+ {selectedSkills.map((skill, index) => (
+
+ {skill.title}
+
+
+ ))}
+
) : null}
);
diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts
index dbc4d667..33c5329b 100644
--- a/frontend/src/hooks/use-iframe-skill.ts
+++ b/frontend/src/hooks/use-iframe-skill.ts
@@ -18,9 +18,41 @@ interface SkillData {
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 [];
+ }
+}
+
// Hook 返回类型
interface UseIframeSkillReturn {
selectedSkill: SkillData | null;
+ selectedSkills: SkillData[];
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
@@ -46,6 +78,7 @@ export function useIframeSkill(
const lastThreadIdRef = useRef(null);
const [selectedSkill, setSelectedSkill] = useState(null);
+ const [selectedSkills, setSelectedSkills] = useState([]);
const [isBootstrapping, setIsBootstrapping] = useState(false);
// 1. 监听 query 参数变化(临时禁用)
@@ -72,16 +105,23 @@ export function useIframeSkill(
const handleMessage = (event: MessageEvent) => {
if (isSelectedSkillMessage(event.data)) {
const { id, title } = event.data;
- setSelectedSkill({ skill_id: String(id), title });
+ const singleSkill = { skill_id: String(id), title };
+ setSelectedSkill(singleSkill);
+ setSelectedSkills([singleSkill]);
return;
}
if (isSelectedSkillsMessage(event.data)) {
- const first = event.data.selectedSkills[0];
- if (!first) {
+ const normalizedSkills = event.data.selectedSkills.map((item) => ({
+ skill_id: String(item.id),
+ title: item.name,
+ }));
+ if (normalizedSkills.length === 0) {
setSelectedSkill(null);
+ setSelectedSkills([]);
return;
}
- setSelectedSkill({ skill_id: String(first.id), title: first.name });
+ setSelectedSkill(normalizedSkills[0] ?? null);
+ setSelectedSkills(normalizedSkills);
return;
}
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
@@ -92,6 +132,34 @@ export function useIframeSkill(
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(() => {
+ if (selectedSkills.length === 0) {
+ return;
+ }
+ const payload = JSON.stringify(selectedSkills);
+ window.localStorage.setItem(STORAGE_KEYS.latest, payload);
+ const threadKey = getThreadStorageKey(threadId);
+ if (threadKey) {
+ window.localStorage.setItem(threadKey, payload);
+ }
+ }, [selectedSkills, threadId]);
+
// 发送选择预定义 skill
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
@@ -156,7 +224,12 @@ export function useIframeSkill(
}
sendSelectSkill(selectedSkills);
- setSelectedSkill({ skill_id: String(content_ids[0]), title });
+ 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} 个文件`,
@@ -191,14 +264,21 @@ export function useIframeSkill(
// 清除选中并发送空 selectedSkills 数组给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
+ setSelectedSkills([]);
+ window.localStorage.removeItem(STORAGE_KEYS.latest);
+ const threadKey = getThreadStorageKey(threadId);
+ if (threadKey) {
+ window.localStorage.removeItem(threadKey);
+ }
// 发送空数组给主页,通知取消选择
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
sendToParent(message);
- }, []);
+ }, [threadId]);
return {
selectedSkill,
+ selectedSkills,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,