feat(frontend): 支持宿主selectedSkills和skill bootstarp流程, 和加载skill中的加载提示与禁止发送消息
This commit is contained in:
parent
dda131c5ea
commit
0b0cbf9c5b
|
|
@ -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 };
|
||||
|
|
@ -291,6 +291,28 @@ export function IframeTestPanel() {
|
|||
>
|
||||
✅ 模拟 selectedSkill(成功)
|
||||
</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
|
||||
size="sm"
|
||||
className="w-full bg-orange-50 text-xs text-orange-700 hover:bg-orange-100"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
CheckIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
|
|
@ -40,7 +41,6 @@ import {
|
|||
usePromptInputController,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfettiButton } from "@/components/ui/confetti-button";
|
||||
import {
|
||||
|
|
@ -56,13 +56,13 @@ import {
|
|||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type {
|
||||
SelectedSkillPayloadItem,
|
||||
} from "@/core/i18n/locales/types";
|
||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { bootstrapRemoteSkill } from "@/core/skills/api";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -130,7 +130,8 @@ export function InputBox({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const iframeSkill = useIframeSkill();
|
||||
const iframeSkill = useIframeSkill({ threadId: threadIdFromProps });
|
||||
const isInputDisabled = (disabled ?? false) || iframeSkill.isBootstrapping;
|
||||
|
||||
const threadId = threadIdFromProps;
|
||||
const { textInput } = usePromptInputController();
|
||||
|
|
@ -326,7 +327,7 @@ export function InputBox({
|
|||
hasSubmitted && "shadow-[0_0_20px_0_rgba(0,0,0,0.10)]!",
|
||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
disabled={isInputDisabled}
|
||||
globalDrop
|
||||
multiple
|
||||
onSubmit={handleSubmit}
|
||||
|
|
@ -341,7 +342,7 @@ export function InputBox({
|
|||
"size-full",
|
||||
!effectiveIsFocused && "h-[80px] py-0 leading-20",
|
||||
)}
|
||||
disabled={disabled}
|
||||
disabled={isInputDisabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={initialValue}
|
||||
|
|
@ -378,6 +379,7 @@ export function InputBox({
|
|||
<IframeSkillDialogButton
|
||||
className="px-2!"
|
||||
selectedSkill={iframeSkill.selectedSkill}
|
||||
isBootstrapping={iframeSkill.isBootstrapping}
|
||||
openSkillDialog={iframeSkill.openSkillDialog}
|
||||
clearSkill={iframeSkill.clearSkill}
|
||||
/>
|
||||
|
|
@ -421,7 +423,7 @@ export function InputBox({
|
|||
</PromptInputFooter>
|
||||
<PromptInputSubmit
|
||||
className="absolute right-3 bottom-5 z-[20] border-0"
|
||||
disabled={disabled}
|
||||
disabled={isInputDisabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
/>
|
||||
|
|
@ -429,8 +431,8 @@ export function InputBox({
|
|||
|
||||
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
|
||||
<SuggestionListContainer
|
||||
threadId={threadId}
|
||||
sendSelectSkill={iframeSkill.sendSelectSkill}
|
||||
bootstrapAndLockSkills={iframeSkill.bootstrapAndLockSkills}
|
||||
isBootstrapping={iframeSkill.isBootstrapping}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -494,29 +496,37 @@ export function InputBox({
|
|||
|
||||
// SuggestionList 容器
|
||||
function SuggestionListContainer({
|
||||
threadId,
|
||||
sendSelectSkill,
|
||||
bootstrapAndLockSkills,
|
||||
isBootstrapping,
|
||||
}: {
|
||||
threadId: string;
|
||||
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||
bootstrapAndLockSkills: (params: {
|
||||
selectedSkills: SelectedSkillPayloadItem[];
|
||||
title: string;
|
||||
}) => Promise<boolean>;
|
||||
isBootstrapping: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 快速选择skillbutton
|
||||
function SuggestionList({
|
||||
threadId,
|
||||
sendSelectSkill,
|
||||
bootstrapAndLockSkills,
|
||||
isBootstrapping,
|
||||
}: {
|
||||
threadId: string;
|
||||
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||
bootstrapAndLockSkills: (params: {
|
||||
selectedSkills: SelectedSkillPayloadItem[];
|
||||
title: string;
|
||||
}) => Promise<boolean>;
|
||||
isBootstrapping: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const { textInput } = usePromptInputController();
|
||||
const suggestions = t.inputBox.suggestions;
|
||||
const promptSuggestions = suggestions.filter(
|
||||
|
|
@ -535,50 +545,30 @@ function SuggestionList({
|
|||
suggestion: string;
|
||||
},
|
||||
) => {
|
||||
const languageTypeRaw =
|
||||
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,
|
||||
});
|
||||
};
|
||||
if (isBootstrapping) return;
|
||||
|
||||
// 优先从 children 中提取 skill_id 数组,转换为 selectedSkills 发送给宿主页
|
||||
// 优先从 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) {
|
||||
sendSelectSkill(
|
||||
childSkillIds.map((id) => ({
|
||||
void bootstrapAndLockSkills({
|
||||
selectedSkills: childSkillIds.map((id) => ({
|
||||
id,
|
||||
name: suggestion.suggestion,
|
||||
})),
|
||||
);
|
||||
bootstrapByIds(childSkillIds);
|
||||
title: suggestion.suggestion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (suggestion.skill_id && suggestion.skill_id.length > 0) {
|
||||
sendSelectSkill(
|
||||
suggestion.skill_id.map((id) => ({
|
||||
void bootstrapAndLockSkills({
|
||||
selectedSkills: suggestion.skill_id.map((id) => ({
|
||||
id,
|
||||
name: suggestion.suggestion,
|
||||
})),
|
||||
);
|
||||
bootstrapByIds(suggestion.skill_id);
|
||||
title: suggestion.suggestion,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 原有逻辑
|
||||
|
|
@ -598,7 +588,7 @@ function SuggestionList({
|
|||
}
|
||||
}, 500);
|
||||
},
|
||||
[textInput, sendSelectSkill, threadId, searchParams],
|
||||
[bootstrapAndLockSkills, isBootstrapping, textInput],
|
||||
);
|
||||
return (
|
||||
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
|
||||
|
|
@ -648,11 +638,13 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
function IframeSkillDialogButton({
|
||||
className,
|
||||
selectedSkill,
|
||||
isBootstrapping,
|
||||
openSkillDialog,
|
||||
clearSkill,
|
||||
}: {
|
||||
className?: string;
|
||||
selectedSkill: { skill_id: string; title: string } | null;
|
||||
isBootstrapping: boolean;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
}) {
|
||||
|
|
@ -678,20 +670,24 @@ function IframeSkillDialogButton({
|
|||
</svg>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
{selectedSkill && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-[#EAE2F5] px-[15px] py-[4px] text-[#8E47F0]"
|
||||
>
|
||||
{isBootstrapping ? (
|
||||
<Tag className="bg-background text-muted-foreground gap-2 border">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{t.common.loading}
|
||||
</Tag>
|
||||
) : null}
|
||||
{!isBootstrapping && selectedSkill ? (
|
||||
<Tag>
|
||||
{selectedSkill.title}
|
||||
<button
|
||||
onClick={clearSkill}
|
||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
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<string | null>(null);
|
||||
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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],
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue