diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index 1204310c..049eb7f8 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -1,6 +1,9 @@ import { getBackendBaseURL } from "@/core/config"; -import type { Skill } from "./type"; +import { + normalizeBootstrapRemoteSkillRequest, +} from "./normalize-bootstrap"; +import type { Skill } from "./types"; export async function loadSkills() { const skills = await fetch(`${getBackendBaseURL()}/api/skills`); @@ -35,9 +38,26 @@ export interface InstallSkillResponse { message: string; } +export interface MaterializeSkillYamlRequest { + thread_id: string; + path: string; + target_dir?: string; + clear_target?: boolean; +} + +export interface MaterializeSkillYamlResponse { + success: boolean; + target_dir: string; + created_directories: number; + created_files: number; + message: string; +} + export interface BootstrapRemoteSkillRequest { thread_id: string; - content_ids: number[]; + content_ids?: number[]; + // Legacy input, kept for minimal compatibility at the API boundary. + content_id?: number; language_type?: number; target_dir?: string; clear_target?: boolean; @@ -46,10 +66,9 @@ export interface BootstrapRemoteSkillRequest { export interface BootstrapRemoteSkillResponse { success: boolean; target_dir: string; - content_ids: number[]; created_directories: number; created_files: number; - sandbox_id: string | null; + sandbox_id: string; message: string; } @@ -79,11 +98,11 @@ export async function installSkill( return response.json(); } -export async function bootstrapRemoteSkill( - request: BootstrapRemoteSkillRequest, -): Promise { +export async function materializeSkillYaml( + request: MaterializeSkillYamlRequest, +): Promise { const response = await fetch( - `${getBackendBaseURL()}/api/skills/bootstrap-remote`, + `${getBackendBaseURL()}/api/skills/materialize-yaml`, { method: "POST", headers: { @@ -102,3 +121,28 @@ export async function bootstrapRemoteSkill( return response.json(); } + +export async function bootstrapRemoteSkill( + request: BootstrapRemoteSkillRequest, +): Promise { + const normalizedRequest = normalizeBootstrapRemoteSkillRequest(request); + const response = await fetch( + `${getBackendBaseURL()}/api/skills/bootstrap-remote`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(normalizedRequest), + }, + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = + errorData.detail ?? `HTTP ${response.status}: ${response.statusText}`; + throw new Error(errorMessage); + } + + return response.json(); +} diff --git a/frontend/src/core/skills/normalize-bootstrap.ts b/frontend/src/core/skills/normalize-bootstrap.ts new file mode 100644 index 00000000..236cf62f --- /dev/null +++ b/frontend/src/core/skills/normalize-bootstrap.ts @@ -0,0 +1,44 @@ +export interface BootstrapRemoteSkillRequestLike { + thread_id: string; + content_ids?: number[]; + content_id?: number; + language_type?: number; + target_dir?: string; + clear_target?: boolean; +} + +export interface NormalizedBootstrapRemoteSkillRequest + extends Omit { + content_ids: number[]; +} + +export function normalizeBootstrapRemoteSkillRequest( + request: BootstrapRemoteSkillRequestLike, +): NormalizedBootstrapRemoteSkillRequest { + const normalizedContentIds = Array.isArray(request.content_ids) + ? request.content_ids + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id) && id > 0) + : []; + + const legacyContentId = + request.content_id != null && Number.isFinite(Number(request.content_id)) + ? Number(request.content_id) + : undefined; + + const contentIds = + normalizedContentIds.length > 0 + ? normalizedContentIds + : legacyContentId != null + ? [legacyContentId] + : []; + + if (contentIds.length === 0) { + throw new Error("content_ids is required."); + } + + return { + ...request, + content_ids: contentIds, + }; +} diff --git a/frontend/src/core/skills/types.ts b/frontend/src/core/skills/types.ts new file mode 100644 index 00000000..79424fdc --- /dev/null +++ b/frontend/src/core/skills/types.ts @@ -0,0 +1 @@ +export type { Skill } from "./type"; diff --git a/frontend/src/hooks/use-selected-skill-listener.ts b/frontend/src/hooks/use-selected-skill-listener.ts new file mode 100644 index 00000000..6769d971 --- /dev/null +++ b/frontend/src/hooks/use-selected-skill-listener.ts @@ -0,0 +1,152 @@ +import { useSearchParams } from "next/navigation"; +import { useEffect, useCallback, useState, useRef } from "react"; +import { toast } from "sonner"; + +import { bootstrapRemoteSkill } from "@/core/skills/api"; + +/** 宿主页发过来的 selectedSkill 消息结构 */ +interface SelectedSkillMessage { + type: "selectedSkill"; + id: number | string; + title: string; +} + +/** 技能基础数据 */ +interface SkillData { + skill_id: string; + title: string; +} + +/** 错误信息状态 */ +interface SkillError { + title: string; + message: string; +} + +interface UseSelectedSkillListenerOptions { + /** 当前会话 thread_id,用于调用 bootstrapRemoteSkill */ + threadId?: string | null; +} + +interface UseSelectedSkillListenerReturn { + /** 当前选中的技能数据(用于 UI 展示,如 Badge) */ + selectedSkill: SkillData | null; + /** 当前错误信息,不为 null 时展示 DevDialog */ + skillError: SkillError | null; + /** 清除错误信息(关闭 DevDialog 时调用) */ + clearSkillError: () => void; + /** 是否正在加载(处理 skill 中) */ + isBootstrapping: boolean; +} + +/** + * 监听宿主页通过 postMessage 发送的 selectedSkill 消息或 URL 中的 skill 参数, + * 收到后自动调用 bootstrapRemoteSkill 接口: + * - 成功:使用 toast 提示 + * - 失败:返回 skillError 供 DevDialog 显示 + */ +export function useSelectedSkillListener({ + threadId, +}: UseSelectedSkillListenerOptions): UseSelectedSkillListenerReturn { + const searchParams = useSearchParams(); + const [selectedSkill, setSelectedSkill] = useState(null); + const [skillError, setSkillError] = useState(null); + const [isBootstrapping, setIsBootstrapping] = useState(false); + + const isFirstLoadRef = useRef(false); + const skillBootstrappedKeyRef = useRef(null); + + const performBootstrap = useCallback( + async (id: number | string, title: string) => { + if (!threadId) return; + + const languageTypeRaw = + searchParams.get("languageType")?.trim() ?? + searchParams.get("language_type")?.trim(); + const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; + + const initKey = `${threadId}:${id}:${languageType}`; + if (skillBootstrappedKeyRef.current === initKey) { + return; + } + + console.log( + `[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`, + ); + setIsBootstrapping(true); + toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); + + try { + const result = await bootstrapRemoteSkill({ + thread_id: threadId, + content_ids: [Number(id)], + language_type: languageType, + target_dir: "/mnt/user-data/uploads/skill", + clear_target: true, + }); + + toast.dismiss("skill-bootstrap"); + + if (result.success) { + skillBootstrappedKeyRef.current = initKey; + toast.success(`技能「${title}」加载成功`, { + description: + result.message || `已创建 ${result.created_files} 个文件`, + duration: 4000, + }); + } else { + setSkillError({ + title: `技能「${title}」加载失败`, + message: result.message || "未知错误", + }); + } + } catch (err) { + toast.dismiss("skill-bootstrap"); + const message = err instanceof Error ? err.message : "网络请求失败"; + setSkillError({ title: `技能「${title}」加载出错`, message }); + } finally { + setIsBootstrapping(false); + } + }, + [threadId, searchParams], + ); + + // 1. URL 初始化集成 + useEffect(() => { + if (!threadId || isFirstLoadRef.current) return; + + const skillIdFromQuery = searchParams.get("skill_id"); + const titleFromQuery = searchParams.get("title"); + if (skillIdFromQuery && titleFromQuery) { + isFirstLoadRef.current = true; + setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); + void performBootstrap(skillIdFromQuery, titleFromQuery); + } + }, [threadId, searchParams, performBootstrap]); + + const handleMessage = useCallback( + (event: MessageEvent) => { + const data = event.data as SelectedSkillMessage; + if (data?.type !== "selectedSkill") return; + + const { id, title } = data; + console.log( + "[useSelectedSkillListener] 收到 postMessage selectedSkill:", + data, + ); + + setSelectedSkill({ skill_id: String(id), title }); + void performBootstrap(id, title); + }, + [performBootstrap], + ); + + useEffect(() => { + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); + + const clearSkillError = useCallback(() => setSkillError(null), []); + + return { selectedSkill, skillError, clearSkillError, isBootstrapping }; +}