feat(frontend): 支持宿主selectedSkills和skill bootstarp流程, 和加载skill中的加载提示与禁止发送消息
This commit is contained in:
parent
a5cf6c87e5
commit
4bbbab24ca
|
|
@ -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(成功)
|
✅ 模拟 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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue