deerflow2/frontend/IFRAME_SKILL_PLAN.md

305 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 劫持)获取宿主页返回的数据