feat(frontend): 支持宿主selectedSkills和skill bootstarp流程, 和加载skill中的加载提示与禁止发送消息

This commit is contained in:
肖应宇 2026-04-08 17:15:08 +08:00 committed by Titan
parent a5cf6c87e5
commit 4bbbab24ca
6 changed files with 281 additions and 88 deletions

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Tag({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="tag"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-transparent bg-[#EAE2F5] px-[15px] py-[4px] text-xs font-medium text-[#8E47F0]",
className,
)}
{...props}
/>
);
}
export { Tag };

View File

@ -291,6 +291,28 @@ export function IframeTestPanel() {
> >
selectedSkill selectedSkill
</Button> </Button>
<Button
size="sm"
className="w-full bg-cyan-50 text-xs text-cyan-700 hover:bg-cyan-100"
variant="ghost"
onClick={() => {
window.postMessage(
{
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
selectedSkills: [
{ id: "5", name: "文档处理" },
{ id: "1216", name: "市场研究报告" },
],
},
"*",
);
addLog(
"模拟宿主页 → selectedSkills [{id:'5',name:'文档处理'},{id:'1216',name:'市场研究报告'}]",
);
}}
>
📦 selectedSkills message
</Button>
<Button <Button
size="sm" size="sm"
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100" className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"

View File

@ -5,6 +5,7 @@ import {
CheckIcon, CheckIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
Loader2Icon,
PaperclipIcon, PaperclipIcon,
PlusIcon, PlusIcon,
SparklesIcon, SparklesIcon,
@ -40,7 +41,6 @@ import {
usePromptInputController, usePromptInputController,
type PromptInputMessage, type PromptInputMessage,
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfettiButton } from "@/components/ui/confetti-button"; import { ConfettiButton } from "@/components/ui/confetti-button";
import { import {
@ -56,13 +56,13 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tag } from "@/components/ui/tag";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { import type {
SelectedSkillPayloadItem, SelectedSkillPayloadItem,
} from "@/core/i18n/locales/types"; } from "@/core/i18n/locales/types";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { bootstrapRemoteSkill } from "@/core/skills/api";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -130,7 +130,8 @@ export function InputBox({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const iframeSkill = useIframeSkill(); const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
const threadId = threadIdFromProps; const threadId = threadIdFromProps;
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
@ -326,7 +327,7 @@ export function InputBox({
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!", hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
effectiveIsFocused ? "h-[200px]" : "h-[80px]", effectiveIsFocused ? "h-[200px]" : "h-[80px]",
)} )}
disabled={disabled} disabled={isInputDisabled}
globalDrop globalDrop
multiple multiple
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -341,7 +342,7 @@ export function InputBox({
"size-full", "size-full",
!effectiveIsFocused && "h-[80px] py-0 leading-20", !effectiveIsFocused && "h-[80px] py-0 leading-20",
)} )}
disabled={disabled} disabled={isInputDisabled}
placeholder={t.inputBox.placeholder} placeholder={t.inputBox.placeholder}
autoFocus={autoFocus} autoFocus={autoFocus}
defaultValue={initialValue} defaultValue={initialValue}
@ -378,6 +379,7 @@ export function InputBox({
<IframeSkillDialogButton <IframeSkillDialogButton
className="px-2!" className="px-2!"
selectedSkill={iframeSkill.selectedSkill} selectedSkill={iframeSkill.selectedSkill}
isBootstrapping={iframeSkill.isBootstrapping}
openSkillDialog={iframeSkill.openSkillDialog} openSkillDialog={iframeSkill.openSkillDialog}
clearSkill={iframeSkill.clearSkill} clearSkill={iframeSkill.clearSkill}
/> />
@ -421,7 +423,7 @@ export function InputBox({
</PromptInputFooter> </PromptInputFooter>
<PromptInputSubmit <PromptInputSubmit
className="absolute right-3 bottom-5 z-[20] border-0" className="absolute right-3 bottom-5 z-[20] border-0"
disabled={disabled} disabled={isInputDisabled}
variant="outline" variant="outline"
status={status} status={status}
/> />
@ -429,8 +431,8 @@ export function InputBox({
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && ( {showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
<SuggestionListContainer <SuggestionListContainer
threadId={threadId} bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
sendSelectSkill={iframeSkill.sendSelectSkill} isBootstrapping={iframeSkill.isBootstrapping}
/> />
)} )}
@ -494,29 +496,37 @@ export function InputBox({
// SuggestionList 容器 // SuggestionList 容器
function SuggestionListContainer({ function SuggestionListContainer({
threadId, bootstrapAndLockSkills,
sendSelectSkill, isBootstrapping,
}: { }: {
threadId: string; bootstrapAndLockSkills: (params: {
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) { }) {
return ( return (
<div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4"> <div className="absolute right-0 bottom-0 left-0 z-0 flex translate-y-full items-center justify-center pt-4">
<SuggestionList threadId={threadId} sendSelectSkill={sendSelectSkill} /> <SuggestionList
bootstrapAndLockSkills={bootstrapAndLockSkills}
isBootstrapping={isBootstrapping}
/>
</div> </div>
); );
} }
// 快速选择skillbutton // 快速选择skillbutton
function SuggestionList({ function SuggestionList({
threadId, bootstrapAndLockSkills,
sendSelectSkill, isBootstrapping,
}: { }: {
threadId: string; bootstrapAndLockSkills: (params: {
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
isBootstrapping: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const searchParams = useSearchParams();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const suggestions = t.inputBox.suggestions; const suggestions = t.inputBox.suggestions;
const promptSuggestions = suggestions.filter( const promptSuggestions = suggestions.filter(
@ -535,50 +545,30 @@ function SuggestionList({
suggestion: string; suggestion: string;
}, },
) => { ) => {
const languageTypeRaw = if (isBootstrapping) return;
searchParams.get("languageType")?.trim() ??
searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const bootstrapByIds = (ids: string[]) => {
const content_ids = Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0),
),
);
if (!threadId || content_ids.length === 0) return;
void bootstrapRemoteSkill({
thread_id: threadId,
content_ids,
language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill",
clear_target: true,
});
};
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页 // 优先从 children 中提取 skill_id 数组,成功 bootstrap 后再同步到宿主页
const childSkillIds = (suggestion.children ?? []) const childSkillIds = (suggestion.children ?? [])
.map((item) => String(item.id).trim()) .map((item) => String(item.id).trim())
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
if (childSkillIds.length > 0) { if (childSkillIds.length > 0) {
sendSelectSkill( void bootstrapAndLockSkills({
childSkillIds.map((id) => ({ selectedSkills: childSkillIds.map((id) => ({
id, id,
name: suggestion.suggestion, name: suggestion.suggestion,
})), })),
); title: suggestion.suggestion,
bootstrapByIds(childSkillIds); });
return; return;
} }
if (suggestion.skill_id && suggestion.skill_id.length > 0) { if (suggestion.skill_id && suggestion.skill_id.length > 0) {
sendSelectSkill( void bootstrapAndLockSkills({
suggestion.skill_id.map((id) => ({ selectedSkills: suggestion.skill_id.map((id) => ({
id, id,
name: suggestion.suggestion, name: suggestion.suggestion,
})), })),
); title: suggestion.suggestion,
bootstrapByIds(suggestion.skill_id); });
return; return;
} }
// 原有逻辑 // 原有逻辑
@ -598,7 +588,7 @@ function SuggestionList({
} }
}, 500); }, 500);
}, },
[textInput, sendSelectSkill, threadId, searchParams], [bootstrapAndLockSkills, isBootstrapping, textInput],
); );
return ( return (
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions"> <Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
@ -648,11 +638,13 @@ function AddAttachmentsButton({ className }: { className?: string }) {
function IframeSkillDialogButton({ function IframeSkillDialogButton({
className, className,
selectedSkill, selectedSkill,
isBootstrapping,
openSkillDialog, openSkillDialog,
clearSkill, clearSkill,
}: { }: {
className?: string; className?: string;
selectedSkill: { skill_id: string; title: string } | null; selectedSkill: { skill_id: string; title: string } | null;
isBootstrapping: boolean;
openSkillDialog: () => void; openSkillDialog: () => void;
clearSkill: () => void; clearSkill: () => void;
}) { }) {
@ -678,20 +670,24 @@ function IframeSkillDialogButton({
</svg> </svg>
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
{selectedSkill && ( {isBootstrapping ? (
<Badge <Tag className="bg-background text-muted-foreground gap-2 border">
variant="secondary" <Loader2Icon className="size-3 animate-spin" />
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]" {t.common.loading}
> </Tag>
) : null}
{!isBootstrapping && selectedSkill ? (
<Tag>
{selectedSkill.title} {selectedSkill.title}
<button <button
onClick={clearSkill} onClick={clearSkill}
className="hover:bg-muted-foreground/20 ml-1 rounded-full" className="hover:bg-muted-foreground/20 ml-1 rounded-full"
type="button"
> >
<XIcon className="size-3" /> <XIcon className="size-3" />
</button> </button>
</Badge> </Tag>
)} ) : null}
</div> </div>
); );
} }

View File

@ -21,6 +21,8 @@ export const POST_MESSAGE_TYPES = {
export const RECEIVE_MESSAGE_TYPES = { export const RECEIVE_MESSAGE_TYPES = {
// 选中的 skill 数据 // 选中的 skill 数据
SELECTED_SKILL: "selectedSkill", SELECTED_SKILL: "selectedSkill",
// 选中的 skills 数据(数组)
SELECTED_SKILLS: "selectedSkills",
} as const; } as const;
// 消息类型 // 消息类型
@ -80,6 +82,25 @@ export function isSelectedSkillMessage(value: unknown): value is SelectedSkillMe
return isValidId && typeof title === "string" && title.trim().length > 0; 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( export function sendToParent(
message: message:

View File

@ -1,13 +1,16 @@
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { toast } from "sonner";
import { import {
POST_MESSAGE_TYPES, POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES, RECEIVE_MESSAGE_TYPES,
isSelectedSkillMessage, isSelectedSkillMessage,
isSelectedSkillsMessage,
type SelectedSkillPayloadItem, type SelectedSkillPayloadItem,
sendToParent, sendToParent,
} from "@/core/iframe-messages"; } from "@/core/iframe-messages";
import { bootstrapRemoteSkill } from "@/core/skills/api";
// Skill 数据类型 // Skill 数据类型
interface SkillData { interface SkillData {
@ -18,19 +21,32 @@ interface SkillData {
// Hook 返回类型 // Hook 返回类型
interface UseIframeSkillReturn { interface UseIframeSkillReturn {
selectedSkill: SkillData | null; selectedSkill: SkillData | null;
isBootstrapping: boolean;
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void; sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
bootstrapAndLockSkills: (params: {
selectedSkills: SelectedSkillPayloadItem[];
title: string;
}) => Promise<boolean>;
openSkillDialog: () => void; openSkillDialog: () => void;
clearSkill: () => void; clearSkill: () => void;
} }
export function useIframeSkill(): UseIframeSkillReturn { interface UseIframeSkillOptions {
threadId?: string | null;
}
export function useIframeSkill(
options?: UseIframeSkillOptions,
): UseIframeSkillReturn {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const threadIdFromQuery = searchParams.get("thread_id"); const threadIdFromQuery = searchParams.get("thread_id");
const threadId = options?.threadId?.trim() || threadIdFromQuery;
const isChattingFromQuery = searchParams.get("is_chatting"); const isChattingFromQuery = searchParams.get("is_chatting");
const lastThreadIdRef = useRef<string | null>(null); const lastThreadIdRef = useRef<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null); const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
const [isBootstrapping, setIsBootstrapping] = useState(false);
// 1. 监听 query 参数变化(临时禁用) // 1. 监听 query 参数变化(临时禁用)
// TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。 // TODO: 当前 skill 仅通过 iframe postMessage 传递,暂不从 URL 读取 skill_id/title。
@ -44,25 +60,33 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面 // 0. 监听 query 中 is_chatting=true 且带 thread_id 时跳转到 thread 页面
useEffect(() => { useEffect(() => {
if (!threadIdFromQuery) return; if (!threadId) return;
if (isChattingFromQuery !== "true") return; if (isChattingFromQuery !== "true") return;
if (lastThreadIdRef.current === threadIdFromQuery) return; if (lastThreadIdRef.current === threadId) return;
lastThreadIdRef.current = threadIdFromQuery; lastThreadIdRef.current = threadId;
router.replace(`/workspace/chats/${threadIdFromQuery}`); router.replace(`/workspace/chats/${threadId}`);
}, [isChattingFromQuery, router, threadIdFromQuery]); }, [isChattingFromQuery, router, threadId]);
// 2. 监听宿主页 postMessage // 2. 监听宿主页 postMessage
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (event.data?.type !== RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) { if (isSelectedSkillMessage(event.data)) {
return;
}
if (!isSelectedSkillMessage(event.data)) {
console.warn("[useIframeSkill] 忽略非法 selectedSkill 消息", event.data);
return;
}
const { id, title } = event.data; const { id, title } = event.data;
setSelectedSkill({ skill_id: String(id), title }); setSelectedSkill({ skill_id: String(id), title });
return;
}
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);
}
}; };
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);
@ -75,6 +99,85 @@ export function useIframeSkill(): UseIframeSkillReturn {
sendToParent(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) {
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 选择对话框 // 打开 skill 选择对话框
const openSkillDialog = useCallback(() => { const openSkillDialog = useCallback(() => {
const message = { const message = {
@ -94,5 +197,12 @@ export function useIframeSkill(): UseIframeSkillReturn {
sendToParent(message); sendToParent(message);
}, []); }, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; return {
selectedSkill,
isBootstrapping,
sendSelectSkill,
bootstrapAndLockSkills,
openSkillDialog,
clearSkill,
};
} }

View File

@ -2,7 +2,11 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useCallback, useState, useRef } from "react"; import { useEffect, useCallback, useState, useRef } from "react";
import { toast } from "sonner"; 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"; import { bootstrapRemoteSkill } from "@/core/skills/api";
/** 技能基础数据 */ /** 技能基础数据 */
@ -51,14 +55,20 @@ export function useSelectedSkillListener({
const skillBootstrappedKeyRef = useRef<string | null>(null); const skillBootstrappedKeyRef = useRef<string | null>(null);
const performBootstrap = useCallback( const performBootstrap = useCallback(
async (id: number | string, title: string) => { async (skills: SelectedSkillPayloadItem[], title: string) => {
if (!threadId) return; if (!threadId) return;
const contentId = Number(id); const contentIds = Array.from(
if (!Number.isFinite(contentId) || contentId <= 0) { new Set(
console.warn("[useSelectedSkillListener] 忽略非法 skill id", id); 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({ setSkillError({
title: `技能「${title}」加载失败`, title: `技能「${title}」加载失败`,
message: `非法 skill id: ${String(id)}`, message: "非法 skill_id 数组",
}); });
return; return;
} }
@ -68,13 +78,13 @@ export function useSelectedSkillListener({
searchParams.get("language_type")?.trim(); searchParams.get("language_type")?.trim();
const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0; const languageType = languageTypeRaw ? Number(languageTypeRaw) : 0;
const initKey = `${threadId}:${id}:${languageType}`; const initKey = `${threadId}:${contentIds.join(",")}:${languageType}`;
if (skillBootstrappedKeyRef.current === initKey) { if (skillBootstrappedKeyRef.current === initKey) {
return; return;
} }
console.log( console.log(
`[useSelectedSkillListener] 开始初始化技能: ${title} (${id})`, `[useSelectedSkillListener] 开始初始化技能: ${title} (${contentIds.join(",")})`,
); );
setIsBootstrapping(true); setIsBootstrapping(true);
toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" }); toast.loading(`正在加载技能「${title}」...`, { id: "skill-bootstrap" });
@ -82,7 +92,7 @@ export function useSelectedSkillListener({
try { try {
const result = await bootstrapRemoteSkill({ const result = await bootstrapRemoteSkill({
thread_id: threadId, thread_id: threadId,
content_ids: [contentId], content_ids: contentIds,
language_type: languageType, language_type: languageType,
target_dir: "/mnt/user-data/uploads/skill", target_dir: "/mnt/user-data/uploads/skill",
clear_target: true, clear_target: true,
@ -123,23 +133,39 @@ export function useSelectedSkillListener({
if (skillIdFromQuery && titleFromQuery) { if (skillIdFromQuery && titleFromQuery) {
isFirstLoadRef.current = true; isFirstLoadRef.current = true;
setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery }); setSelectedSkill({ skill_id: skillIdFromQuery, title: titleFromQuery });
void performBootstrap(skillIdFromQuery, titleFromQuery); void performBootstrap(
[{ id: skillIdFromQuery, name: titleFromQuery }],
titleFromQuery,
);
} }
}, [threadId, searchParams, performBootstrap]); }, [threadId, searchParams, performBootstrap]);
const handleMessage = useCallback( const handleMessage = useCallback(
(event: MessageEvent) => { (event: MessageEvent) => {
if (!isSelectedSkillMessage(event.data)) return; if (isSelectedSkillMessage(event.data)) {
const data = event.data; const data = event.data;
const { id, title } = data; const { id, title } = data;
console.log( console.log(
"[useSelectedSkillListener] 收到 postMessage selectedSkill:", "[useSelectedSkillListener] 收到 postMessage selectedSkill:",
data, data,
); );
setSelectedSkill({ skill_id: String(id), title }); setSelectedSkill({ skill_id: String(id), title });
void performBootstrap(id, title); void performBootstrap([{ id, name: title }], title);
return;
}
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], [performBootstrap],
); );