Compare commits

..

No commits in common. "70ab78cbb00269dd350bcf80bf6e2c52d56cdbac" and "5efe4191f83cd1e45502b6ddb0ac08b32d8cc881" have entirely different histories.

5 changed files with 41 additions and 199 deletions

View File

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

View File

@ -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所以暂时禁用取消选择按钮 */} className="hover:bg-muted-foreground/20 ml-1 rounded-full"
{/* <button type="button"
onClick={() => clearSkill(skill.skill_id)} >
className="hover:bg-muted-foreground/20 ml-1 rounded-full" <XIcon className="size-3" />
type="button" </button>
> </Tag>
<XIcon className="size-3" />
</button> */}
</Tag>
))}
</div>
) : null} ) : null}
</div> </div>
); );

View File

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

View File

@ -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)); sendToParent(message);
}, []);
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);
},
[selectedSkills, threadId],
);
return { return {
selectedSkill, selectedSkill,
selectedSkills,
isBootstrapping, isBootstrapping,
sendSelectSkill, sendSelectSkill,
bootstrapAndLockSkills, bootstrapAndLockSkills,

View File

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