diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index cb8cc2b4..1c0d9a40 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -378,7 +378,7 @@ export function InputBox({ { 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,