feat: 支持多技能标签展示并持久化已选技能
This commit is contained in:
parent
12a40d8e49
commit
c1ab79e2cb
|
|
@ -378,7 +378,7 @@ export function InputBox({
|
||||||
<AddAttachmentsButton className="px-2!" />
|
<AddAttachmentsButton className="px-2!" />
|
||||||
<IframeSkillDialogButton
|
<IframeSkillDialogButton
|
||||||
className="px-2!"
|
className="px-2!"
|
||||||
selectedSkill={iframeSkill.selectedSkill}
|
selectedSkills={iframeSkill.selectedSkills}
|
||||||
isBootstrapping={iframeSkill.isBootstrapping}
|
isBootstrapping={iframeSkill.isBootstrapping}
|
||||||
openSkillDialog={iframeSkill.openSkillDialog}
|
openSkillDialog={iframeSkill.openSkillDialog}
|
||||||
clearSkill={iframeSkill.clearSkill}
|
clearSkill={iframeSkill.clearSkill}
|
||||||
|
|
@ -547,16 +547,18 @@ function SuggestionList({
|
||||||
) => {
|
) => {
|
||||||
if (isBootstrapping) return;
|
if (isBootstrapping) return;
|
||||||
|
|
||||||
// 优先从 children 中提取 skill_id 数组,成功 bootstrap 后再同步到宿主页
|
// 优先使用 children 中的 skill(保留每个 skill 自己的 name,用于 tag 展示)
|
||||||
const childSkillIds = (suggestion.children ?? [])
|
const childSkills = (suggestion.children ?? [])
|
||||||
.map((item) => String(item.id).trim())
|
.map((item) => ({
|
||||||
.filter((id): id is string => Boolean(id));
|
id: String(item.id).trim(),
|
||||||
if (childSkillIds.length > 0) {
|
name: item.name?.trim() ?? "",
|
||||||
|
}))
|
||||||
|
.filter((item): item is { id: string; name: string } =>
|
||||||
|
Boolean(item.id) && Boolean(item.name),
|
||||||
|
);
|
||||||
|
if (childSkills.length > 0) {
|
||||||
void bootstrapAndLockSkills({
|
void bootstrapAndLockSkills({
|
||||||
selectedSkills: childSkillIds.map((id) => ({
|
selectedSkills: childSkills,
|
||||||
id,
|
|
||||||
name: suggestion.suggestion,
|
|
||||||
})),
|
|
||||||
title: suggestion.suggestion,
|
title: suggestion.suggestion,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -637,13 +639,13 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
||||||
// 启动iframeSkillDialog
|
// 启动iframeSkillDialog
|
||||||
function IframeSkillDialogButton({
|
function IframeSkillDialogButton({
|
||||||
className,
|
className,
|
||||||
selectedSkill,
|
selectedSkills,
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
openSkillDialog,
|
openSkillDialog,
|
||||||
clearSkill,
|
clearSkill,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedSkill: { skill_id: string; title: string } | null;
|
selectedSkills: Array<{ skill_id: string; title: string }>;
|
||||||
isBootstrapping: boolean;
|
isBootstrapping: boolean;
|
||||||
openSkillDialog: () => void;
|
openSkillDialog: () => void;
|
||||||
clearSkill: () => void;
|
clearSkill: () => void;
|
||||||
|
|
@ -676,17 +678,21 @@ function IframeSkillDialogButton({
|
||||||
{t.common.loading}
|
{t.common.loading}
|
||||||
</Tag>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{!isBootstrapping && selectedSkill ? (
|
{!isBootstrapping && selectedSkills.length > 0 ? (
|
||||||
<Tag>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{selectedSkill.title}
|
{selectedSkills.map((skill, index) => (
|
||||||
<button
|
<Tag key={`${skill.skill_id}-${skill.title}-${index}`}>
|
||||||
onClick={clearSkill}
|
{skill.title}
|
||||||
className="hover:bg-muted-foreground/20 ml-1 rounded-full"
|
<button
|
||||||
type="button"
|
onClick={clearSkill}
|
||||||
>
|
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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,41 @@ 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: {
|
||||||
|
|
@ -46,6 +78,7 @@ 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 参数变化(临时禁用)
|
||||||
|
|
@ -72,16 +105,23 @@ 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;
|
||||||
setSelectedSkill({ skill_id: String(id), title });
|
const singleSkill = { skill_id: String(id), title };
|
||||||
|
setSelectedSkill(singleSkill);
|
||||||
|
setSelectedSkills([singleSkill]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSelectedSkillsMessage(event.data)) {
|
if (isSelectedSkillsMessage(event.data)) {
|
||||||
const first = event.data.selectedSkills[0];
|
const normalizedSkills = event.data.selectedSkills.map((item) => ({
|
||||||
if (!first) {
|
skill_id: String(item.id),
|
||||||
|
title: item.name,
|
||||||
|
}));
|
||||||
|
if (normalizedSkills.length === 0) {
|
||||||
setSelectedSkill(null);
|
setSelectedSkill(null);
|
||||||
|
setSelectedSkills([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedSkill({ skill_id: String(first.id), title: first.name });
|
setSelectedSkill(normalizedSkills[0] ?? null);
|
||||||
|
setSelectedSkills(normalizedSkills);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
|
||||||
|
|
@ -92,6 +132,34 @@ 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(() => {
|
||||||
|
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
|
// 发送选择预定义 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 };
|
||||||
|
|
@ -156,7 +224,12 @@ export function useIframeSkill(
|
||||||
}
|
}
|
||||||
|
|
||||||
sendSelectSkill(selectedSkills);
|
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}」加载成功`, {
|
toast.success(`技能「${title}」加载成功`, {
|
||||||
description: result.message || `已创建 ${result.created_files} 个文件`,
|
description: result.message || `已创建 ${result.created_files} 个文件`,
|
||||||
|
|
@ -191,14 +264,21 @@ export function useIframeSkill(
|
||||||
// 清除选中并发送空 selectedSkills 数组给主页
|
// 清除选中并发送空 selectedSkills 数组给主页
|
||||||
const clearSkill = useCallback(() => {
|
const clearSkill = useCallback(() => {
|
||||||
setSelectedSkill(null);
|
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: [] };
|
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILLS, selectedSkills: [] };
|
||||||
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
console.log("[useIframeSkill] clearSkill, sending selectedSkills=[]:", message);
|
||||||
sendToParent(message);
|
sendToParent(message);
|
||||||
}, []);
|
}, [threadId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedSkill,
|
selectedSkill,
|
||||||
|
selectedSkills,
|
||||||
isBootstrapping,
|
isBootstrapping,
|
||||||
sendSelectSkill,
|
sendSelectSkill,
|
||||||
bootstrapAndLockSkills,
|
bootstrapAndLockSkills,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue