feat: 支持多技能标签展示并持久化已选技能
This commit is contained in:
parent
739d10a6ec
commit
f68c09f90a
|
|
@ -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,13 +639,13 @@ 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;
|
||||
|
|
@ -676,17 +678,21 @@ 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}
|
||||
<button
|
||||
onClick={clearSkill}
|
||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
@ -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,34 @@ 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(() => {
|
||||
if (selectedSkills.length === 0) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.stringify(selectedSkills);
|
||||
window.localStorage.setItem(STORAGE_KEYS.latest, payload);
|
||||
const threadKey = getThreadStorageKey(threadId);
|
||||
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 +224,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} 个文件`,
|
||||
|
|
@ -191,14 +264,21 @@ export function useIframeSkill(
|
|||
// 清除选中并发送空 selectedSkills 数组给主页
|
||||
const clearSkill = useCallback(() => {
|
||||
setSelectedSkill(null);
|
||||
setSelectedSkills([]);
|
||||
window.localStorage.removeItem(STORAGE_KEYS.latest);
|
||||
const threadKey = getThreadStorageKey(threadId);
|
||||
if (threadKey) {
|
||||
window.localStorage.removeItem(threadKey);
|
||||
}
|
||||
// 发送空数组给主页,通知取消选择
|
||||
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||
sendToParent(message);
|
||||
}, []);
|
||||
}, [threadId]);
|
||||
|
||||
return {
|
||||
selectedSkill,
|
||||
selectedSkills,
|
||||
isBootstrapping,
|
||||
sendSelectSkill,
|
||||
bootstrapAndLockSkills,
|
||||
|
|
|
|||
Loading…
Reference in New Issue