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 48c48a188e
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
</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"

View File

@ -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>
);
}

View File

@ -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:

View File

@ -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,
};
}

View File

@ -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],
);