diff --git a/frontend/src/components/ui/tag.tsx b/frontend/src/components/ui/tag.tsx new file mode 100644 index 00000000..6ec1506a --- /dev/null +++ b/frontend/src/components/ui/tag.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Tag({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { Tag }; diff --git a/frontend/src/components/workspace/iframe-test-panel.tsx b/frontend/src/components/workspace/iframe-test-panel.tsx index 6c10042f..e8d8c327 100644 --- a/frontend/src/components/workspace/iframe-test-panel.tsx +++ b/frontend/src/components/workspace/iframe-test-panel.tsx @@ -291,6 +291,28 @@ export function IframeTestPanel() { > ✅ 模拟 selectedSkill(成功) + - - )} + + ) : null} ); } diff --git a/frontend/src/core/iframe-messages.ts b/frontend/src/core/iframe-messages.ts index 80d8f8d7..f413f0fc 100644 --- a/frontend/src/core/iframe-messages.ts +++ b/frontend/src/core/iframe-messages.ts @@ -21,6 +21,8 @@ export const POST_MESSAGE_TYPES = { export const RECEIVE_MESSAGE_TYPES = { // 选中的 skill 数据 SELECTED_SKILL: "selectedSkill", + // 选中的 skills 数据(数组) + SELECTED_SKILLS: "selectedSkills", } as const; // 消息类型 @@ -80,6 +82,25 @@ export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMe return isValidId && typeof title === "string" && title.trim().length > 0; } +export function isSelectedSkillsMessage(value: unknown): value is SelectSkillMessage { + const record = asRecord(value); + if (record?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILLS) { + return false; + } + const selectedSkills = record.selectedSkills; + if (!Array.isArray(selectedSkills)) { + return false; + } + return selectedSkills.every((item) => { + const skill = asRecord(item); + if (!skill) return false; + const id = skill.id; + const name = skill.name; + const isValidId = typeof id === "string" || typeof id === "number"; + return isValidId && typeof name === "string" && name.trim().length > 0; + }); +} + // 发送消息的辅助函数 export function sendToParent( message: diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts index 71b18851..c8928176 100644 --- a/frontend/src/hooks/use-iframe-skill.ts +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -1,13 +1,16 @@ 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 { @@ -18,19 +21,32 @@ interface SkillData { // Hook 返回类型 interface UseIframeSkillReturn { selectedSkill: SkillData | null; + isBootstrapping: boolean; sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; + bootstrapAndLockSkills: (params: { + selectedSkills: SelectedSkillPayloadItem[]; + title: string; + }) => Promise; openSkillDialog: () => void; clearSkill: () => void; } -export function useIframeSkill(): UseIframeSkillReturn { +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 [isBootstrapping, setIsBootstrapping] = useState(false); // 1. 监听 query 参数变化(临时禁用) // TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。 @@ -44,25 +60,33 @@ export function useIframeSkill(): UseIframeSkillReturn { // 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面 useEffect(() => { - if (!threadIdFromQuery) return; + if (!threadId) return; if (isChattingFromQuery !== "true") return; - if (lastThreadIdRef.current === threadIdFromQuery) return; - lastThreadIdRef.current = threadIdFromQuery; - router.replace(`/workspace/chats/${threadIdFromQuery}`); - }, [isChattingFromQuery, router, threadIdFromQuery]); + if (lastThreadIdRef.current === threadId) return; + lastThreadIdRef.current = threadId; + router.replace(`/workspace/chats/${threadId}`); + }, [isChattingFromQuery, router, threadId]); // 2. 监听宿主页 postMessage useEffect(() => { const handleMessage = (event: MessageEvent) => { - if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { + if (isSelectedSkillMessage(event.data)) { + const { id, title } = event.data; + setSelectedSkill({ skill_id: String(id), title }); return; } - if (!isSelectedSkillMessage(event.data)) { + 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); - return; } - const { id, title } = event.data; - setSelectedSkill({ skill_id: String(id), title }); }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); @@ -75,6 +99,85 @@ export function useIframeSkill(): UseIframeSkillReturn { 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 = { @@ -94,5 +197,12 @@ export function useIframeSkill(): UseIframeSkillReturn { sendToParent(message); }, []); - return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; + return { + selectedSkill, + isBootstrapping, + sendSelectSkill, + bootstrapAndLockSkills, + openSkillDialog, + clearSkill, + }; } diff --git a/frontend/src/hooks/use-selected-skill-listener.ts b/frontend/src/hooks/use-selected-skill-listener.ts index 7fd6721e..126007a1 100644 --- a/frontend/src/hooks/use-selected-skill-listener.ts +++ b/frontend/src/hooks/use-selected-skill-listener.ts @@ -2,7 +2,11 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useCallback, useState, useRef } from "react"; import { toast } from "sonner"; -import { isSelectedSkillMessage } from "@/core/iframe-messages"; +import { + isSelectedSkillMessage, + isSelectedSkillsMessage, + type SelectedSkillPayloadItem, +} from "@/core/iframe-messages"; import { bootstrapRemoteSkill } from "@/core/skills/api"; /** 技能基础数据 */ @@ -51,14 +55,20 @@ export function useSelectedSkillListener({ const skillBootstrappedKeyRef = useRef(null); const performBootstrap = useCallback( - async (id: number | string, title: string) => { + async (skills: SelectedSkillPayloadItem[], title: string) => { if (!threadId) return; - const contentId = Number(id); - if (!Number.isFinite(contentId) || contentId <= 0) { - console.warn("[useSelectedSkillListener] 忽略非法 skill id", id); + const contentIds = Array.from( + new Set( + skills + .map((skill) => Number(String(skill.id).trim())) + .filter((id) => Number.isFinite(id) && id > 0), + ), + ); + if (contentIds.length === 0) { + console.warn("[useSelectedSkillListener] 忽略非法 skill ids", skills); setSkillError({ title: `技能「${title}」加载失败`, - message: `非法 skill id: ${String(id)}`, + message: "非法 skill_id 数组", }); return; } @@ -68,13 +78,13 @@ export function useSelectedSkillListener({ searchParams.get("language_type")?.trim(); const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; - const initKey = `${threadId}:${id}:${languageType}`; + const initKey = `${threadId}:${contentIds.join(",")}:${languageType}`; if (skillBootstrappedKeyRef.current === initKey) { return; } console.log( - `[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`, + `[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`, ); setIsBootstrapping(true); toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); @@ -82,7 +92,7 @@ export function useSelectedSkillListener({ try { const result = await bootstrapRemoteSkill({ thread_id: threadId, - content_ids: [contentId], + content_ids: contentIds, language_type: languageType, target_dir: "/mnt/user-data/uploads/skill", clear_target: true, @@ -123,23 +133,39 @@ export function useSelectedSkillListener({ if (skillIdFromQuery && titleFromQuery) { isFirstLoadRef.current = true; setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); - void performBootstrap(skillIdFromQuery, titleFromQuery); + void performBootstrap( + [{ id: skillIdFromQuery, name: titleFromQuery }], + titleFromQuery, + ); } }, [threadId, searchParams, performBootstrap]); const handleMessage = useCallback( (event: MessageEvent) => { - if (!isSelectedSkillMessage(event.data)) return; - const data = event.data; + if (isSelectedSkillMessage(event.data)) { + const data = event.data; + const { id, title } = data; + console.log( + "[useSelectedSkillListener] 收到 postMessage selectedSkill:", + data, + ); + setSelectedSkill({ skill_id: String(id), title }); + void performBootstrap([{ id, name: title }], title); + return; + } - const { id, title } = data; - console.log( - "[useSelectedSkillListener] 收到 postMessage selectedSkill:", - data, - ); - - setSelectedSkill({ skill_id: String(id), title }); - void performBootstrap(id, title); + if (isSelectedSkillsMessage(event.data)) { + const { selectedSkills } = event.data; + if (!selectedSkills.length) return; + const first = selectedSkills[0]!; + const firstTitle = first.name; + console.log( + "[useSelectedSkillListener] 收到 postMessage selectedSkills:", + event.data, + ); + setSelectedSkill({ skill_id: String(first.id), title: firstTitle }); + void performBootstrap(selectedSkills, firstTitle); + } }, [performBootstrap], );