305 lines
9.4 KiB
Markdown
305 lines
9.4 KiB
Markdown
# Iframe Skill 选择功能实现计划
|
||
|
||
## Context
|
||
实现 iframe 与宿主页之间的 skill 选择通信功能:
|
||
1. 点击预选 Skill 的 Suggestion 按钮时,向宿主页发送 `{ type: 'selectSkill', skill_id }`,宿主页返回 `{ skill_id, title }`
|
||
2. 点击 IframeSkillDialogButton 时,向宿主页发送 `{ type: 'openSkillDialog', openSkillDialog: true }`,宿主页返回用户选择的 `{ skill_id, title }`
|
||
3. 在 IframeSkillDialogButton 右侧显示可删除的 Tag 标签(显示 title)
|
||
|
||
目前 skill 为单选,但设计需考虑后期多选扩展。
|
||
|
||
## 现有代码分析
|
||
|
||
### 关键文件
|
||
- `src/components/workspace/input-box.tsx` - 包含 `IframeSkillDialogButton` (L942-958) 和 `SuggestionList` (L854-921)
|
||
- `src/components/ui/badge.tsx` - Badge 组件,可用于创建可删除 Tag
|
||
- `src/hooks/use-mobile.ts` - 现有 hook 示例
|
||
- `src/core/i18n/locales/types.ts` (L85-89) - suggestions 类型定义
|
||
- `src/core/i18n/locales/zh-CN.ts` (L101-127) - 中文 suggestion 数据
|
||
|
||
### IframeSkillDialogButton 现状
|
||
```tsx
|
||
// L942-958: 目前只是一个按钮,点击时打开文件对话框
|
||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||
const { t } = useI18n();
|
||
const attachments = usePromptInputAttachments();
|
||
return (
|
||
<Tooltip content={t.inputBox.selectSkill}>
|
||
<PromptInputButton onClick={() => attachments.openFileDialog()}>
|
||
{/* SVG icon */}
|
||
</PromptInputButton>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
```
|
||
位置:在 input-box.tsx L496 处使用:`<IframeSkillDialogButton className="px-2!"/>`
|
||
|
||
### SuggestionList 现状
|
||
```tsx
|
||
// L854-921: 渲染预定义的建议按钮列表
|
||
{t.inputBox.suggestions.map((suggestion) => (
|
||
<Suggestion
|
||
key={suggestion.suggestion}
|
||
icon={suggestion.icon}
|
||
suggestion={suggestion.suggestion}
|
||
onClick={() => handleSuggestionClick(suggestion.prompt)}
|
||
/>
|
||
))}
|
||
```
|
||
|
||
## Files to Modify
|
||
|
||
### 1. 新建 Hook: `src/hooks/use-iframe-skill.ts`
|
||
创建 iframe skill 通信 hook:
|
||
|
||
```typescript
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
|
||
// 消息类型常量
|
||
const MESSAGE_TYPES = {
|
||
SELECT_SKILL: 'selectSkill',
|
||
OPEN_SKILL_DIALOG: 'openSkillDialog',
|
||
} as const;
|
||
|
||
// Skill 数据类型
|
||
interface SkillData {
|
||
skill_id: string;
|
||
title: string;
|
||
}
|
||
|
||
// Hook 返回类型
|
||
interface UseIframeSkillReturn {
|
||
selectedSkill: SkillData | null;
|
||
sendSelectSkill: (skill_id: string) => void;
|
||
openSkillDialog: () => void;
|
||
clearSkill: () => void;
|
||
}
|
||
|
||
// 从 URL query 参数解析 skill 数据
|
||
function parseSkillFromQuery(): SkillData | null {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const skill_id = params.get('skill_id');
|
||
const title = params.get('title');
|
||
if (skill_id && title) {
|
||
return { skill_id, title };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function useIframeSkill(): UseIframeSkillReturn {
|
||
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(() => parseSkillFromQuery());
|
||
|
||
// 发送选择预定义 skill
|
||
const sendSelectSkill = useCallback((skill_id: string) => {
|
||
window.parent.postMessage({ type: MESSAGE_TYPES.SELECT_SKILL, skill_id }, '*');
|
||
}, []);
|
||
|
||
// 打开 skill 选择对话框
|
||
const openSkillDialog = useCallback(() => {
|
||
window.parent.postMessage({ type: MESSAGE_TYPES.OPEN_SKILL_DIALOG, openSkillDialog: true }, '*');
|
||
}, []);
|
||
|
||
// 清除选中
|
||
const clearSkill = useCallback(() => {
|
||
setSelectedSkill(null);
|
||
}, []);
|
||
|
||
// 监听 URL 变化(宿主页通过修改 query 参数返回数据)
|
||
useEffect(() => {
|
||
const handleUrlChange = () => {
|
||
const skill = parseSkillFromQuery();
|
||
if (skill) {
|
||
setSelectedSkill(skill);
|
||
}
|
||
};
|
||
|
||
// 监听 popstate 事件(URL 变化时触发)
|
||
window.addEventListener('popstate', handleUrlChange);
|
||
|
||
// 使用 MutationObserver 监听 URL 变化(某些情况下 popstate 不触发)
|
||
const originalPushState = history.pushState;
|
||
history.pushState = function(...args) {
|
||
originalPushState.apply(history, args);
|
||
handleUrlChange();
|
||
};
|
||
|
||
return () => {
|
||
window.removeEventListener('popstate', handleUrlChange);
|
||
history.pushState = originalPushState;
|
||
};
|
||
}, []);
|
||
|
||
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };
|
||
}
|
||
```
|
||
|
||
### 2. 修改类型定义: `src/core/i18n/locales/types.ts` (L85-89)
|
||
扩展 suggestions 类型,添加可选的 `skill_id` 字段:
|
||
|
||
```typescript
|
||
// 原代码
|
||
suggestions: {
|
||
suggestion: string;
|
||
prompt: string;
|
||
icon: LucideIcon;
|
||
}[];
|
||
|
||
// 修改为
|
||
suggestions: {
|
||
suggestion: string;
|
||
prompt: string;
|
||
icon: LucideIcon;
|
||
skill_id?: string; // 新增:可选的 skill_id,用于 iframe 通信
|
||
}[];
|
||
```
|
||
|
||
### 3. 修改 i18n 数据: `src/core/i18n/locales/zh-CN.ts` (L101-127)
|
||
为需要发送 skill_id 的 suggestion 添加字段:
|
||
|
||
```typescript
|
||
suggestions: [
|
||
{
|
||
suggestion: "论文写作",
|
||
prompt: "撰写一篇关于[主题]的学术论文...",
|
||
icon: PenLineIcon,
|
||
skill_id: "paper-writing", // 新增
|
||
},
|
||
// ... 其他建议类似添加
|
||
],
|
||
```
|
||
|
||
### 4. 修改组件: `src/components/workspace/input-box.tsx`
|
||
|
||
#### 4.1 导入 hook (文件顶部)
|
||
```typescript
|
||
import { useIframeSkill } from "@/hooks/use-iframe-skill";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { XIcon } from "lucide-react";
|
||
```
|
||
|
||
#### 4.2 修改 IframeSkillDialogButton (L942-958)
|
||
```typescript
|
||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||
const { t } = useI18n();
|
||
const { selectedSkill, openSkillDialog, clearSkill } = useIframeSkill();
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Tooltip content={t.inputBox.selectSkill}>
|
||
<PromptInputButton
|
||
className={cn("group px-2! hover:!bg-[#EAE2F5]", className)}
|
||
onClick={openSkillDialog} // 改为调用 openSkillDialog
|
||
>
|
||
{/* SVG icon 保持不变 */}
|
||
</PromptInputButton>
|
||
</Tooltip>
|
||
{/* 显示选中的 skill Tag */}
|
||
{selectedSkill && (
|
||
<Badge variant="secondary" className="gap-1 pr-1">
|
||
{selectedSkill.title}
|
||
<button
|
||
onClick={clearSkill}
|
||
className="ml-1 rounded-full hover:bg-muted-foreground/20"
|
||
>
|
||
<XIcon className="size-3" />
|
||
</button>
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 4.3 修改 SuggestionList (L854-921)
|
||
```typescript
|
||
function SuggestionList() {
|
||
const { t } = useI18n();
|
||
const { textInput } = usePromptInputController();
|
||
const { sendSelectSkill } = useIframeSkill();
|
||
|
||
const handleSuggestionClick = useCallback(
|
||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||
// 如果有 skill_id,发送给宿主页
|
||
if (suggestion.skill_id) {
|
||
sendSelectSkill(suggestion.skill_id);
|
||
return; // 可选:是否继续填充 prompt
|
||
}
|
||
// 原有逻辑
|
||
if (!suggestion.prompt) return;
|
||
textInput.setInput(suggestion.prompt);
|
||
// ... 其余代码不变
|
||
},
|
||
[textInput, sendSelectSkill],
|
||
);
|
||
|
||
return (
|
||
<Suggestions className="min-h-16 w-fit items-start">
|
||
{t.inputBox.suggestions.map((suggestion) => (
|
||
<Suggestion
|
||
key={suggestion.suggestion}
|
||
icon={suggestion.icon}
|
||
suggestion={suggestion.suggestion}
|
||
onClick={() => handleSuggestionClick(suggestion)}
|
||
/>
|
||
))}
|
||
{/* DropdownMenu 部分不变 */}
|
||
</Suggestions>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Message Protocol
|
||
|
||
### 发送到宿主页 (iframe → parent)
|
||
使用 `window.parent.postMessage()`:
|
||
```typescript
|
||
// 选择预定义 skill
|
||
{ type: 'selectSkill', skill_id: string }
|
||
|
||
// 打开 skill 选择对话框
|
||
{ type: 'openSkillDialog', openSkillDialog: true }
|
||
```
|
||
|
||
### 宿主页返回 (parent → iframe)
|
||
宿主页通过修改 iframe 的 URL query 参数返回数据:
|
||
```
|
||
?skill_id=xxx&title=xxx
|
||
```
|
||
|
||
iframe 监听 URL 变化获取数据。
|
||
|
||
## Implementation Steps
|
||
|
||
1. **创建 useIframeSkill hook**
|
||
- 新建文件 `src/hooks/use-iframe-skill.ts`
|
||
- 实现 postMessage 发送和 message 事件监听
|
||
|
||
2. **扩展 i18n 类型**
|
||
- 修改 `src/core/i18n/locales/types.ts`
|
||
- 添加 skill_id 可选字段
|
||
|
||
3. **修改 SuggestionList**
|
||
- 导入 useIframeSkill hook
|
||
- 修改 handleSuggestionClick,检测 skill_id 时发送消息
|
||
|
||
4. **修改 IframeSkillDialogButton**
|
||
- 导入 useIframeSkill hook 和 Badge 组件
|
||
- 点击调用 openSkillDialog
|
||
- 显示选中的 skill Tag,支持删除
|
||
|
||
5. **添加 i18n 数据**
|
||
- 为 zh-CN.ts 和 en-US.ts 的 suggestions 添加 skill_id
|
||
|
||
## Verification
|
||
1. 在 iframe 环境中测试:
|
||
- 点击带 skill_id 的 Suggestion,确认消息发送(可在宿主页控制台查看)
|
||
- 模拟宿主页返回 `{ skill_id: 'test', title: '测试技能' }`,确认 Tag 显示正确
|
||
- 点击 IframeSkillDialogButton,确认 openSkillDialog 消息发送
|
||
- 点击 Tag 的 X 按钮,确认删除功能正常
|
||
|
||
## Notes
|
||
- 目前 skill 为单选,selectedSkill 类型为 `SkillData | null`
|
||
- 后期扩展多选时,改为 `SkillData[]` 数组类型
|
||
- iframe → 宿主页:使用 `postMessage`
|
||
- 宿主页 → iframe:通过修改 URL query 参数 `?skill_id=xxx&title=xxx`
|
||
- hook 通过监听 URL 变化(popstate + history.pushState 劫持)获取宿主页返回的数据 |