Compare commits
4 Commits
5efe4191f8
...
70ab78cbb0
| Author | SHA1 | Date |
|---|---|---|
|
|
70ab78cbb0 | |
|
|
cea4232813 | |
|
|
25c444e83d | |
|
|
f68c09f90a |
|
|
@ -313,6 +313,23 @@ export function IframeTestPanel() {
|
|||
>
|
||||
📦 模拟 selectedSkills(数组 message)
|
||||
</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
|
||||
size="sm"
|
||||
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!" />
|
||||
<IframeSkillDialogButton
|
||||
className="px-2!"
|
||||
selectedSkill={iframeSkill.selectedSkill}
|
||||
selectedSkills={iframeSkill.selectedSkills}
|
||||
isBootstrapping={iframeSkill.isBootstrapping}
|
||||
openSkillDialog={iframeSkill.openSkillDialog}
|
||||
clearSkill={iframeSkill.clearSkill}
|
||||
|
|
@ -547,16 +547,18 @@ function SuggestionList({
|
|||
) => {
|
||||
if (isBootstrapping) return;
|
||||
|
||||
// 优先从 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) {
|
||||
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
||||
const childSkills = (suggestion.children ?? [])
|
||||
.map((item) => ({
|
||||
id: String(item.id).trim(),
|
||||
name: item.name?.trim() ?? "",
|
||||
}))
|
||||
.filter((item): item is { id: string; name: string } =>
|
||||
Boolean(item.id) && Boolean(item.name),
|
||||
);
|
||||
if (childSkills.length > 0) {
|
||||
void bootstrapAndLockSkills({
|
||||
selectedSkills: childSkillIds.map((id) => ({
|
||||
id,
|
||||
name: suggestion.suggestion,
|
||||
})),
|
||||
selectedSkills: childSkills,
|
||||
title: suggestion.suggestion,
|
||||
});
|
||||
return;
|
||||
|
|
@ -637,16 +639,16 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
// 启动iframeSkillDialog
|
||||
function IframeSkillDialogButton({
|
||||
className,
|
||||
selectedSkill,
|
||||
selectedSkills,
|
||||
isBootstrapping,
|
||||
openSkillDialog,
|
||||
clearSkill,
|
||||
}: {
|
||||
className?: string;
|
||||
selectedSkill: { skill_id: string; title: string } | null;
|
||||
selectedSkills: Array<{ skill_id: string; title: string }>;
|
||||
isBootstrapping: boolean;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
clearSkill: (skillId?: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -676,17 +678,22 @@ function IframeSkillDialogButton({
|
|||
{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>
|
||||
</Tag>
|
||||
{!isBootstrapping && selectedSkills.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedSkills.map((skill, index) => (
|
||||
<Tag key={`${skill.skill_id}-${skill.title}-${index}`}>
|
||||
{skill.title}
|
||||
{/* TODO: 因为后端接口不支持取消选择skill,所以暂时禁用取消选择按钮 */}
|
||||
{/* <button
|
||||
onClick={() => clearSkill(skill.skill_id)}
|
||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button> */}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export const POST_MESSAGE_TYPES = {
|
|||
FULLSCREEN: "fullscreen",
|
||||
// 会话是否处于聊天态
|
||||
IS_CHATTING: "isChatting",
|
||||
// 请求宿主页执行复制
|
||||
COPY_TO_CLIPBOARD: "copyToClipboard",
|
||||
// 选择预定义 skill
|
||||
SELECT_SKILLS: "selectedSkills",
|
||||
// 打开 skill 选择对话框
|
||||
|
|
@ -42,6 +44,11 @@ export interface IsChattingMessage {
|
|||
isChatting: boolean;
|
||||
}
|
||||
|
||||
export interface CopyToClipboardMessage {
|
||||
type: typeof POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SelectSkillMessage {
|
||||
type: typeof POST_MESSAGE_TYPES.SELECT_SKILLS;
|
||||
selectedSkills: SelectedSkillPayloadItem[];
|
||||
|
|
@ -106,6 +113,7 @@ export function sendToParent(
|
|||
message:
|
||||
| FullscreenMessage
|
||||
| IsChattingMessage
|
||||
| CopyToClipboardMessage
|
||||
| SelectSkillMessage
|
||||
| OpenSkillDialogMessage,
|
||||
): void {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,41 @@ interface SkillData {
|
|||
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 返回类型
|
||||
interface UseIframeSkillReturn {
|
||||
selectedSkill: SkillData | null;
|
||||
selectedSkills: SkillData[];
|
||||
isBootstrapping: boolean;
|
||||
sendSelectSkill: (selectedSkills: SelectedSkillPayloadItem[]) => void;
|
||||
bootstrapAndLockSkills: (params: {
|
||||
|
|
@ -28,7 +60,7 @@ interface UseIframeSkillReturn {
|
|||
title: string;
|
||||
}) => Promise<boolean>;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
clearSkill: (skillId?: string) => void;
|
||||
}
|
||||
|
||||
interface UseIframeSkillOptions {
|
||||
|
|
@ -46,6 +78,7 @@ export function useIframeSkill(
|
|||
const lastThreadIdRef = useRef<string | null>(null);
|
||||
|
||||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
|
||||
const [selectedSkills, setSelectedSkills] = useState<SkillData[]>([]);
|
||||
const [isBootstrapping, setIsBootstrapping] = useState(false);
|
||||
|
||||
// 1. 监听 query 参数变化(临时禁用)
|
||||
|
|
@ -72,16 +105,23 @@ export function useIframeSkill(
|
|||
const handleMessage = (event: MessageEvent) => {
|
||||
if (isSelectedSkillMessage(event.data)) {
|
||||
const { id, title } = event.data;
|
||||
setSelectedSkill({ skill_id: String(id), title });
|
||||
const singleSkill = { skill_id: String(id), title };
|
||||
setSelectedSkill(singleSkill);
|
||||
setSelectedSkills([singleSkill]);
|
||||
return;
|
||||
}
|
||||
if (isSelectedSkillsMessage(event.data)) {
|
||||
const first = event.data.selectedSkills[0];
|
||||
if (!first) {
|
||||
const normalizedSkills = event.data.selectedSkills.map((item) => ({
|
||||
skill_id: String(item.id),
|
||||
title: item.name,
|
||||
}));
|
||||
if (normalizedSkills.length === 0) {
|
||||
setSelectedSkill(null);
|
||||
setSelectedSkills([]);
|
||||
return;
|
||||
}
|
||||
setSelectedSkill({ skill_id: String(first.id), title: first.name });
|
||||
setSelectedSkill(normalizedSkills[0] ?? null);
|
||||
setSelectedSkills(normalizedSkills);
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||
|
|
@ -92,6 +132,40 @@ export function useIframeSkill(
|
|||
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
|
||||
const sendSelectSkill = useCallback((selectedSkills: SelectedSkillPayloadItem[]) => {
|
||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills };
|
||||
|
|
@ -156,7 +230,12 @@ export function useIframeSkill(
|
|||
}
|
||||
|
||||
sendSelectSkill(selectedSkills);
|
||||
setSelectedSkill({ skill_id: String(content_ids[0]), title });
|
||||
const normalizedSkills = selectedSkills.map((item) => ({
|
||||
skill_id: String(item.id),
|
||||
title: item.name,
|
||||
}));
|
||||
setSelectedSkill(normalizedSkills[0] ?? null);
|
||||
setSelectedSkills(normalizedSkills);
|
||||
|
||||
toast.success(`技能「${title}」加载成功`, {
|
||||
description: result.message || `已创建 ${result.created_files} 个文件`,
|
||||
|
|
@ -189,16 +268,62 @@ export function useIframeSkill(
|
|||
}, []);
|
||||
|
||||
// 清除选中并发送空 selectedSkills 数组给主页
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
// 发送空数组给主页,通知取消选择
|
||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||
sendToParent(message);
|
||||
}, []);
|
||||
const clearSkill = useCallback(
|
||||
(skillId?: string) => {
|
||||
const removeAll = !skillId;
|
||||
const nextSelectedSkills = removeAll
|
||||
? []
|
||||
: 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);
|
||||
},
|
||||
[selectedSkills, threadId],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedSkill,
|
||||
selectedSkills,
|
||||
isBootstrapping,
|
||||
sendSelectSkill,
|
||||
bootstrapAndLockSkills,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -18,14 +19,14 @@ export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
|||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
const isInIframe = window.self !== window.top;
|
||||
const message = {
|
||||
type: "copyToClipboard",
|
||||
type: POST_MESSAGE_TYPES.COPY_TO_CLIPBOARD,
|
||||
text,
|
||||
};
|
||||
} as const;
|
||||
|
||||
if (isInIframe && window.parent) {
|
||||
if (isInIframe) {
|
||||
try {
|
||||
// Request parent window to copy
|
||||
window.parent.postMessage(message, "*");
|
||||
sendToParent(message);
|
||||
console.log(
|
||||
"[copyToClipboard] iframe mode → postMessage to parent",
|
||||
message,
|
||||
|
|
|
|||
Loading…
Reference in New Issue