Compare commits
No commits in common. "70ab78cbb00269dd350bcf80bf6e2c52d56cdbac" and "5efe4191f83cd1e45502b6ddb0ac08b32d8cc881" have entirely different histories.
70ab78cbb0
...
5efe4191f8
|
|
@ -313,23 +313,6 @@ export function IframeTestPanel() {
|
||||||
>
|
>
|
||||||
📦 模拟 selectedSkills(数组 message)
|
📦 模拟 selectedSkills(数组 message)
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
window.postMessage(
|
|
||||||
{
|
|
||||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
|
||||||
selectedSkills: [],
|
|
||||||
},
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
addLog("模拟宿主页 → selectedSkills []");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🧹 模拟 selectedSkills(空数组)
|
|
||||||
</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"
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,7 @@ export function InputBox({
|
||||||
<AddAttachmentsButton className="px-2!" />
|
<AddAttachmentsButton className="px-2!" />
|
||||||
<IframeSkillDialogButton
|
<IframeSkillDialogButton
|
||||||
className="px-2!"
|
className="px-2!"
|
||||||
selectedSkills={iframeSkill.selectedSkills}
|
selectedSkill={iframeSkill.selectedSkill}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
isBootstrapping={iframeSkill.isBootstrapping}
|
||||||
openSkillDialog={iframeSkill.openSkillDialog}
|
openSkillDialog={iframeSkill.openSkillDialog}
|
||||||
clearSkill={iframeSkill.clearSkill}
|
clearSkill={iframeSkill.clearSkill}
|
||||||
|
|
@ -547,18 +547,16 @@ function SuggestionList({
|
||||||
) => {
|
) => {
|
||||||
if (isBootstrapping) return;
|
if (isBootstrapping) return;
|
||||||
|
|
||||||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
// 优先从 children 中提取 skill_id 数组,成功 bootstrap 后再同步到宿主页
|
||||||
const childSkills = (suggestion.children ?? [])
|
const childSkillIds = (suggestion.children ?? [])
|
||||||
.map((item) => ({
|
.map((item) => String(item.id).trim())
|
||||||
id: String(item.id).trim(),
|
.filter((id): id is string => Boolean(id));
|
||||||
name: item.name?.trim() ?? "",
|
if (childSkillIds.length > 0) {
|
||||||
}))
|
|
||||||
.filter((item): item is { id: string; name: string } =>
|
|
||||||
Boolean(item.id) && Boolean(item.name),
|
|
||||||
);
|
|
||||||
if (childSkills.length > 0) {
|
|
||||||
void bootstrapAndLockSkills({
|
void bootstrapAndLockSkills({
|
||||||
selectedSkills: childSkills,
|
selectedSkills: childSkillIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: suggestion.suggestion,
|
||||||
|
})),
|
||||||
title: suggestion.suggestion,
|
title: suggestion.suggestion,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -639,16 +637,16 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||||
// 启动iframeSkillDialog
|
// 启动iframeSkillDialog
|
||||||
function IframeSkillDialogButton({
|
function IframeSkillDialogButton({
|
||||||
className,
|
className,
|
||||||
selectedSkills,
|
selectedSkill,
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
openSkillDialog,
|
openSkillDialog,
|
||||||
clearSkill,
|
clearSkill,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedSkills: Array<{ skill_id: string; title: string }>;
|
selectedSkill: { skill_id: string; title: string } | null;
|
||||||
isBootstrapping: boolean;
|
isBootstrapping: boolean;
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -678,22 +676,17 @@ function IframeSkillDialogButton({
|
||||||
{t.common.loading}
|
{t.common.loading}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{!isBootstrapping && selectedSkills.length > 0 ? (
|
{!isBootstrapping && selectedSkill ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Tag>
|
||||||
{selectedSkills.map((skill, index) => (
|
{selectedSkill.title}
|
||||||
<Tag key={`${skill.skill_id}-${skill.title}-${index}`}>
|
<button
|
||||||
{skill.title}
|
onClick={clearSkill}
|
||||||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
|
||||||
{/* <button
|
|
||||||
onClick={() => clearSkill(skill.skill_id)}
|
|
||||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<XIcon className="size-3" />
|
<XIcon className="size-3" />
|
||||||
</button> */}
|
</button>
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ export const POST_MESSAGE_TYPES = {
|
||||||
FULLSCREEN: "fullscreen",
|
FULLSCREEN: "fullscreen",
|
||||||
// 会话是否处于聊天态
|
// 会话是否处于聊天态
|
||||||
IS_CHATTING: "isChatting",
|
IS_CHATTING: "isChatting",
|
||||||
// 请求宿主页执行复制
|
|
||||||
COPY_TO_CLIPBOARD: "copyToClipboard",
|
|
||||||
// 选择预定义 skill
|
// 选择预定义 skill
|
||||||
SELECT_SKILLS: "selectedSkills",
|
SELECT_SKILLS: "selectedSkills",
|
||||||
// 打开 skill 选择对话框
|
// 打开 skill 选择对话框
|
||||||
|
|
@ -44,11 +42,6 @@ export interface IsChattingMessage {
|
||||||
isChatting: boolean;
|
isChatting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CopyToClipboardMessage {
|
|
||||||
type: typeof POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectSkillMessage {
|
export interface SelectSkillMessage {
|
||||||
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
|
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
|
||||||
selectedSkills: SelectedSkillPayloadItem[];
|
selectedSkills: SelectedSkillPayloadItem[];
|
||||||
|
|
@ -113,7 +106,6 @@ export function sendToParent(
|
||||||
message:
|
message:
|
||||||
| FullscreenMessage
|
| FullscreenMessage
|
||||||
| IsChattingMessage
|
| IsChattingMessage
|
||||||
| CopyToClipboardMessage
|
|
||||||
| SelectSkillMessage
|
| SelectSkillMessage
|
||||||
| OpenSkillDialogMessage,
|
| OpenSkillDialogMessage,
|
||||||
): void {
|
): void {
|
||||||
|
|
|
||||||
|
|
@ -18,41 +18,9 @@ interface SkillData {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
latest: "iframe:selectedSkills:latest",
|
|
||||||
byThreadPrefix: "iframe:selectedSkills:thread:",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function getThreadStorageKey(threadId?: string | null): string | null {
|
|
||||||
const normalized = threadId?.trim();
|
|
||||||
if (!normalized) return null;
|
|
||||||
return `${STORAGE_KEYS.byThreadPrefix}${normalized}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStoredSkills(raw: string | null): SkillData[] {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
|
||||||
if (!Array.isArray(parsed)) return [];
|
|
||||||
return parsed
|
|
||||||
.map((item) => {
|
|
||||||
if (typeof item !== "object" || item === null) return null;
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
const skillId = String(record.skill_id ?? "").trim();
|
|
||||||
const title = String(record.title ?? "").trim();
|
|
||||||
if (!skillId || !title) return null;
|
|
||||||
return { skill_id: skillId, title };
|
|
||||||
})
|
|
||||||
.filter((item): item is SkillData => item !== null);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook 返回类型
|
// Hook 返回类型
|
||||||
interface UseIframeSkillReturn {
|
interface UseIframeSkillReturn {
|
||||||
selectedSkill: SkillData | null;
|
selectedSkill: SkillData | null;
|
||||||
selectedSkills: SkillData[];
|
|
||||||
isBootstrapping: boolean;
|
isBootstrapping: boolean;
|
||||||
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||||
bootstrapAndLockSkills: (params: {
|
bootstrapAndLockSkills: (params: {
|
||||||
|
|
@ -60,7 +28,7 @@ interface UseIframeSkillReturn {
|
||||||
title: string;
|
title: string;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: (skillId?: string) => void;
|
clearSkill: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseIframeSkillOptions {
|
interface UseIframeSkillOptions {
|
||||||
|
|
@ -78,7 +46,6 @@ export function useIframeSkill(
|
||||||
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 [selectedSkills, setSelectedSkills] = useState<SkillData[]>([]);
|
|
||||||
const [isBootstrapping, setIsBootstrapping] = useState(false);
|
const [isBootstrapping, setIsBootstrapping] = useState(false);
|
||||||
|
|
||||||
// 1. 监听 query 参数变化(临时禁用)
|
// 1. 监听 query 参数变化(临时禁用)
|
||||||
|
|
@ -105,23 +72,16 @@ export function useIframeSkill(
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = (event: MessageEvent) => {
|
||||||
if (isSelectedSkillMessage(event.data)) {
|
if (isSelectedSkillMessage(event.data)) {
|
||||||
const { id, title } = event.data;
|
const { id, title } = event.data;
|
||||||
const singleSkill = { skill_id: String(id), title };
|
setSelectedSkill({ skill_id: String(id), title });
|
||||||
setSelectedSkill(singleSkill);
|
|
||||||
setSelectedSkills([singleSkill]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSelectedSkillsMessage(event.data)) {
|
if (isSelectedSkillsMessage(event.data)) {
|
||||||
const normalizedSkills = event.data.selectedSkills.map((item) => ({
|
const first = event.data.selectedSkills[0];
|
||||||
skill_id: String(item.id),
|
if (!first) {
|
||||||
title: item.name,
|
|
||||||
}));
|
|
||||||
if (normalizedSkills.length === 0) {
|
|
||||||
setSelectedSkill(null);
|
setSelectedSkill(null);
|
||||||
setSelectedSkills([]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedSkill(normalizedSkills[0] ?? null);
|
setSelectedSkill({ skill_id: String(first.id), title: first.name });
|
||||||
setSelectedSkills(normalizedSkills);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
|
|
@ -132,40 +92,6 @@ export function useIframeSkill(
|
||||||
return () => window.removeEventListener("message", handleMessage);
|
return () => window.removeEventListener("message", handleMessage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 3. 首次进入时恢复 localStorage 中上次选择的 skill(线程优先,其次全局)
|
|
||||||
useEffect(() => {
|
|
||||||
const threadKey = getThreadStorageKey(threadId);
|
|
||||||
const threadSkills = threadKey
|
|
||||||
? parseStoredSkills(window.localStorage.getItem(threadKey))
|
|
||||||
: [];
|
|
||||||
const latestSkills = parseStoredSkills(
|
|
||||||
window.localStorage.getItem(STORAGE_KEYS.latest),
|
|
||||||
);
|
|
||||||
const restoredSkills = threadSkills.length > 0 ? threadSkills : latestSkills;
|
|
||||||
if (restoredSkills.length === 0) return;
|
|
||||||
setSelectedSkills(restoredSkills);
|
|
||||||
setSelectedSkill(restoredSkills[0] ?? null);
|
|
||||||
}, [threadId]);
|
|
||||||
|
|
||||||
// 4. 选择变化时同步到 localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
const threadKey = getThreadStorageKey(threadId);
|
|
||||||
if (selectedSkills.length === 0) {
|
|
||||||
// 空数组也要同步到存储,避免 UI 状态与缓存不一致
|
|
||||||
window.localStorage.removeItem(STORAGE_KEYS.latest);
|
|
||||||
if (threadKey) {
|
|
||||||
window.localStorage.removeItem(threadKey);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.stringify(selectedSkills);
|
|
||||||
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
|
|
||||||
if (threadKey) {
|
|
||||||
window.localStorage.setItem(threadKey, payload);
|
|
||||||
}
|
|
||||||
}, [selectedSkills, threadId]);
|
|
||||||
|
|
||||||
// 发送选择预定义 skill
|
// 发送选择预定义 skill
|
||||||
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
|
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
|
||||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
|
||||||
|
|
@ -230,12 +156,7 @@ export function useIframeSkill(
|
||||||
}
|
}
|
||||||
|
|
||||||
sendSelectSkill(selectedSkills);
|
sendSelectSkill(selectedSkills);
|
||||||
const normalizedSkills = selectedSkills.map((item) => ({
|
setSelectedSkill({ skill_id: String(content_ids[0]), title });
|
||||||
skill_id: String(item.id),
|
|
||||||
title: item.name,
|
|
||||||
}));
|
|
||||||
setSelectedSkill(normalizedSkills[0] ?? null);
|
|
||||||
setSelectedSkills(normalizedSkills);
|
|
||||||
|
|
||||||
toast.success(`技能「${title}」加载成功`, {
|
toast.success(`技能「${title}」加载成功`, {
|
||||||
description: result.message || `已创建 ${result.created_files} 个文件`,
|
description: result.message || `已创建 ${result.created_files} 个文件`,
|
||||||
|
|
@ -268,62 +189,16 @@ export function useIframeSkill(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 清除选中并发送空 selectedSkills 数组给主页
|
// 清除选中并发送空 selectedSkills 数组给主页
|
||||||
const clearSkill = useCallback(
|
const clearSkill = useCallback(() => {
|
||||||
(skillId?: string) => {
|
setSelectedSkill(null);
|
||||||
const removeAll = !skillId;
|
// 发送空数组给主页,通知取消选择
|
||||||
const nextSelectedSkills = removeAll
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||||
? []
|
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||||
: selectedSkills.filter((skill) => skill.skill_id !== String(skillId));
|
|
||||||
|
|
||||||
setSelectedSkills(nextSelectedSkills);
|
|
||||||
setSelectedSkill(nextSelectedSkills[0] ?? null);
|
|
||||||
|
|
||||||
// 同步 latest 缓存:仅删除对应 skill(或全部清空)
|
|
||||||
const latestSkills = parseStoredSkills(
|
|
||||||
window.localStorage.getItem(STORAGE_KEYS.latest),
|
|
||||||
);
|
|
||||||
const nextLatestSkills = removeAll
|
|
||||||
? []
|
|
||||||
: latestSkills.filter((skill) => skill.skill_id !== String(skillId));
|
|
||||||
if (nextLatestSkills.length > 0) {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
STORAGE_KEYS.latest,
|
|
||||||
JSON.stringify(nextLatestSkills),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(STORAGE_KEYS.latest);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步线程缓存:保存剩余数组,空则删除 key
|
|
||||||
const threadKey = getThreadStorageKey(threadId);
|
|
||||||
if (threadKey) {
|
|
||||||
if (nextSelectedSkills.length > 0) {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
threadKey,
|
|
||||||
JSON.stringify(nextSelectedSkills),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(threadKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通知宿主页当前剩余技能
|
|
||||||
const message = {
|
|
||||||
type: POST_MESSAGE_TYPES.SELECT_SKILLS,
|
|
||||||
selectedSkills: nextSelectedSkills.map((skill) => ({
|
|
||||||
id: skill.skill_id,
|
|
||||||
name: skill.title,
|
|
||||||
})),
|
|
||||||
} as const;
|
|
||||||
console.log("[useIframeSkill] clearSkill:", message);
|
|
||||||
sendToParent(message);
|
sendToParent(message);
|
||||||
},
|
}, []);
|
||||||
[selectedSkills, threadId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedSkill,
|
selectedSkill,
|
||||||
selectedSkills,
|
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
sendSelectSkill,
|
sendSelectSkill,
|
||||||
bootstrapAndLockSkills,
|
bootstrapAndLockSkills,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|
@ -19,14 +18,14 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
||||||
export async function copyToClipboard(text: string): Promise<void> {
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
const isInIframe = window.self !== window.top;
|
const isInIframe = window.self !== window.top;
|
||||||
const message = {
|
const message = {
|
||||||
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
type: "copyToClipboard",
|
||||||
text,
|
text,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
if (isInIframe) {
|
if (isInIframe && window.parent) {
|
||||||
try {
|
try {
|
||||||
// Request parent window to copy
|
// Request parent window to copy
|
||||||
sendToParent(message);
|
window.parent.postMessage(message, "*");
|
||||||
console.log(
|
console.log(
|
||||||
"[copyToClipboard] iframe mode → postMessage to parent",
|
"[copyToClipboard] iframe mode → postMessage to parent",
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue