diff --git a/frontend/IFRAME_SKILL_PLAN.md b/frontend/IFRAME_SKILL_PLAN.md new file mode 100644 index 00000000..09fe4bac --- /dev/null +++ b/frontend/IFRAME_SKILL_PLAN.md @@ -0,0 +1,305 @@ +# 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 ( + + attachments.openFileDialog()}> + {/* SVG icon */} + + + ); +} +``` +位置:在 input-box.tsx L496 处使用:`` + +### SuggestionList 现状 +```tsx +// L854-921: 渲染预定义的建议按钮列表 +{t.inputBox.suggestions.map((suggestion) => ( + 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(() => 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 ( +
+ + + {/* SVG icon 保持不变 */} + + + {/* 显示选中的 skill Tag */} + {selectedSkill && ( + + {selectedSkill.title} + + + )} +
+ ); +} +``` + +#### 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 ( + + {t.inputBox.suggestions.map((suggestion) => ( + handleSuggestionClick(suggestion)} + /> + ))} + {/* DropdownMenu 部分不变 */} + + ); +} +``` + +## 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 劫持)获取宿主页返回的数据 \ No newline at end of file diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 9fb66dc7..a5fa0dc2 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -68,7 +68,6 @@ export default function ChatPage() { } }, }); - console.log(thread.values.todos); const handleSubmit = useCallback( (message: PromptInputMessage) => { @@ -141,7 +140,7 @@ export default function ChatPage() { )} > (
); diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 296f669c..2fcc7f78 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -247,6 +247,7 @@ export function ArtifactFileDetail({ src={urlOfArtifact({ filepath, threadId, isMock })} /> )} + {/*
*/} ); @@ -261,13 +262,14 @@ export function ArtifactFilePreview({ }) { if (language === "markdown") { return ( -
+
{content ?? ""} +
); diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index dd22041c..67a6d167 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -42,6 +42,7 @@ import { } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; import { ConfettiButton } from "@/components/ui/confetti-button"; +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -82,6 +83,7 @@ import { import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; +import { useIframeSkill } from "@/hooks/use-iframe-skill"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; @@ -453,10 +455,13 @@ export function InputBox({ // height和padding都为0来隐藏 !effectiveIsFocused && "invisible h-[0px] p-[0px] opacity-0 translate-y-2 pointer-events-none" )}> + {/* ========== 左侧工具栏 ========== */} - - - {/* + {/* [已禁用] 模式选择器触发器 (flash/thinking/pro/ultra) */} + {/* + */} - - + {/* Skill 选择按钮 (iframe 与宿主页通信) */} + + {/* [已禁用] 模式选择下拉菜单内容 */} + {/* {t.inputBox.mode} @@ -626,6 +633,7 @@ export function InputBox({ + {/* [已禁用] 推理强度选择器 (minimal/low/medium/high) */} {supportReasoningEffort && context.mode !== "flash" && ( @@ -737,8 +745,10 @@ export function InputBox({ )} + {/* ========== 右侧工具栏 ========== */} - @@ -768,13 +778,14 @@ export function InputBox({ ))} - + */} + {/* 占位符 */}
{/* 移动出来 */} ); } - +// 快速选择skillbutton function SuggestionList() { const { t } = useI18n(); const { textInput } = usePromptInputController(); + const { sendSelectSkill } = useIframeSkill(); + const handleSuggestionClick = useCallback( - (prompt: string | undefined) => { - if (!prompt) return; - textInput.setInput(prompt); + (suggestion: { prompt: string; skill_id?: string }) => { + // 如果有 skill_id,发送给宿主页 + if (suggestion.skill_id) { + sendSelectSkill(suggestion.skill_id); + return; + } + // 原有逻辑 + if (!suggestion.prompt) return; + textInput.setInput(suggestion.prompt); setTimeout(() => { const textarea = document.querySelector( "textarea[name='message']", ); if (textarea) { - const selStart = prompt.indexOf("["); - const selEnd = prompt.indexOf("]"); + const selStart = suggestion.prompt.indexOf("["); + const selEnd = suggestion.prompt.indexOf("]"); if (selStart !== -1 && selEnd !== -1) { textarea.setSelectionRange(selStart, selEnd + 1); textarea.focus(); @@ -872,7 +891,7 @@ function SuggestionList() { } }, 500); }, - [textInput], + [textInput, sendSelectSkill], ); return ( @@ -889,7 +908,7 @@ function SuggestionList() { key={suggestion.suggestion} icon={suggestion.icon} suggestion={suggestion.suggestion} - onClick={() => handleSuggestionClick(suggestion.prompt)} + onClick={() => handleSuggestionClick(suggestion)} /> ))} @@ -905,7 +924,7 @@ function SuggestionList() { !("type" in suggestion) && ( handleSuggestionClick(suggestion.prompt)} + onClick={() => handleSuggestionClick(suggestion)} > {suggestion.icon && } {suggestion.suggestion} @@ -919,14 +938,14 @@ function SuggestionList() { ); } - +// 上传附件 function AddAttachmentsButton({ className }: { className?: string }) { const { t } = useI18n(); const attachments = usePromptInputAttachments(); return ( attachments.openFileDialog()} > @@ -938,21 +957,34 @@ function AddAttachmentsButton({ className }: { className?: string }) { ); } - +// 启动iframeSkillDialog function IframeSkillDialogButton({ className }: { className?: string }) { const { t } = useI18n(); - const attachments = usePromptInputAttachments(); - return ( - - attachments.openFileDialog()} - > + const { selectedSkill, openSkillDialog, clearSkill } = useIframeSkill(); - - - - - + return ( +
+ + + + + + + + {selectedSkill && ( + + {selectedSkill.title} + + + )} +
); } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index f1d879ad..5ef4e4e9 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -107,26 +107,31 @@ export const enUS: Translations = { suggestion: "Paper Writing", prompt: "Write an academic paper about [topic], including abstract, introduction, body and references.", icon: PenLineIcon, + skill_id: "paper-writing", }, { suggestion: "Report Generation", prompt: "Analyze [topic] in depth and generate a well-structured research report.", icon: MicroscopeIcon, + skill_id: "report-generation", }, { suggestion: "Copywriting", prompt: "Create a complete planning proposal and promotional copy for [project/event].", icon: ShapesIcon, + skill_id: "planning-copywriting", }, { suggestion: "PPT Generation", prompt: "Generate a PPT presentation outline and content about [topic].", icon: GraduationCapIcon, + skill_id: "ppt-generation", }, { suggestion: "Document Processing", prompt: "Process [document] with reading, summarizing, translating or format conversion.", icon: CompassIcon, + skill_id: "document-processing", }, ], suggestionsCreate: [ diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 5e28dd59..2d55b061 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -86,6 +86,7 @@ export interface Translations { suggestion: string; prompt: string; icon: LucideIcon; + skill_id?: string; }[]; suggestionsCreate: ( | { diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index bcef133b..6fee3e05 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -103,26 +103,31 @@ export const zhCN: Translations = { suggestion: "论文写作", prompt: "撰写一篇关于[主题]的学术论文,包含摘要、引言、正文和参考文献。", icon: PenLineIcon, + skill_id: "1", }, { suggestion: "报告生成", prompt: "深入分析[主题],生成一份结构清晰的调研报告。", icon: MicroscopeIcon, + skill_id: "2", }, { suggestion: "策划文案", prompt: "为[项目/活动]撰写一份完整的策划方案和宣传文案。", icon: ShapesIcon, + skill_id: "3", }, { suggestion: "PPT生成", prompt: "生成一个关于[主题]的PPT演示文稿大纲和内容。", icon: GraduationCapIcon, + skill_id: "4", }, { suggestion: "文档处理", prompt: "对[文档]进行阅读、总结、翻译或格式转换等处理。", icon: CompassIcon, + skill_id: "5", }, ], suggestionsCreate: [ diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts new file mode 100644 index 00000000..c6bd1938 --- /dev/null +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -0,0 +1,61 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; + +// 消息类型常量 +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; +} + +export function useIframeSkill(): UseIframeSkillReturn { + const searchParams = useSearchParams(); + const skillIdFromQuery = searchParams.get('skill_id'); + const titleFromQuery = searchParams.get('title'); + + const [selectedSkill, setSelectedSkill] = useState(null); + + // 监听 query 参数变化 + useEffect(() => { + console.log('[useIframeSkill] Query params changed - skill_id:', skillIdFromQuery, 'title:', titleFromQuery); + if (skillIdFromQuery && titleFromQuery) { + const skill = { skill_id: skillIdFromQuery, title: titleFromQuery }; + console.log('[useIframeSkill] Setting skill from URL:', skill); + setSelectedSkill(skill); + } + }, [skillIdFromQuery, titleFromQuery]); + + // 发送选择预定义 skill + const sendSelectSkill = useCallback((skill_id: string) => { + const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id }; + console.log('[useIframeSkill] sendSelectSkill:', message); + window.parent.postMessage(message, '*'); + }, []); + + // 打开 skill 选择对话框 + const openSkillDialog = useCallback(() => { + const message = { type: MESSAGE_TYPES.OPEN_SKILL_DIALOG, openSkillDialog: true }; + console.log('[useIframeSkill] openSkillDialog:', message); + window.parent.postMessage(message, '*'); + }, []); + + // 清除选中 + const clearSkill = useCallback(() => { + setSelectedSkill(null); + }, []); + + return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill }; +} \ No newline at end of file