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(成功)
+
@@ -421,7 +423,7 @@ export function InputBox({
@@ -429,8 +431,8 @@ export function InputBox({
{showWelcomeStyle && !hasSubmitted && searchParams.get("mode") !== "skill" && (
)}
@@ -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;
+ isBootstrapping: boolean;
}) {
return (
-
+
);
}
// 快速选择skillbutton
function SuggestionList({
- threadId,
- sendSelectSkill,
+ bootstrapAndLockSkills,
+ isBootstrapping,
}: {
- threadId: string;
- sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
+ bootstrapAndLockSkills: (params: {
+ selectedSkills: SelectedSkillPayloadItem[];
+ title: string;
+ }) => Promise;
+ 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 (
@@ -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({
- {selectedSkill && (
-
+ {isBootstrapping ? (
+
+
+ {t.common.loading}
+
+ ) : null}
+ {!isBootstrapping && selectedSkill ? (
+
{selectedSkill.title}
-
- )}
+
+ ) : 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],
);