deerflow2/frontend/IFRAME_SKILL_PLAN.md

9.4 KiB
Raw Blame History

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 现状

// 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 现状

// 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

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 字段:

// 原代码
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 添加字段:

suggestions: [
  {
    suggestion: "论文写作",
    prompt: "撰写一篇关于[主题]的学术论文...",
    icon: PenLineIcon,
    skill_id: "paper-writing",  // 新增
  },
  // ... 其他建议类似添加
],

4. 修改组件: src/components/workspace/input-box.tsx

4.1 导入 hook (文件顶部)

import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { Badge } from "@/components/ui/badge";
import { XIcon } from "lucide-react";

4.2 修改 IframeSkillDialogButton (L942-958)

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)

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()

// 选择预定义 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 劫持)获取宿主页返回的数据