diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 81f780c8..11d45830 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -20,6 +20,7 @@ import { } from "@/components/workspace/artifacts"; import { useThreadChat } from "@/components/workspace/chats"; import { DevTodoList } from "@/components/workspace/dev-todo-list"; +import { IframeTestPanel } from "@/components/workspace/iframe-test-panel"; import { InputBox } from "@/components/workspace/input-box"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; @@ -29,6 +30,7 @@ import { Tooltip } from "@/components/workspace/tooltip"; import { useSpecificChatMode } from "@/components/workspace/use-chat-mode"; import { Welcome } from "@/components/workspace/welcome"; import { useI18n } from "@/core/i18n/hooks"; +import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; @@ -412,6 +414,10 @@ export default function ChatPage() { await handleStop(); } setShowExitDialog(false); + sendToParent({ + type: POST_MESSAGE_TYPES.XCLAW_USED, + XClawUsed: false, + }); // 使用完整页面刷新确保组件重新挂载,isNewThread 为 true window.location.href = "/workspace/chats/new"; }} @@ -450,8 +456,8 @@ export default function ChatPage() { - {/* MARK: 开发测试:iframe 通信功能测试面板 */} - {/* */} + {/* MARK: 开发测试:iframe 通信功能测试面板 */} + {process.env.NODE_ENV !== "production" && } ); diff --git a/frontend/src/components/workspace/iframe-test-panel.tsx b/frontend/src/components/workspace/iframe-test-panel.tsx index 0967bde9..fdad489b 100644 --- a/frontend/src/components/workspace/iframe-test-panel.tsx +++ b/frontend/src/components/workspace/iframe-test-panel.tsx @@ -1,9 +1,10 @@ "use client"; import { useSearchParams, useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useRef, useState, type PointerEvent } from "react"; import { Button } from "@/components/ui/button"; +import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { copyToClipboard } from "@/lib/utils"; import { cn } from "@/lib/utils"; @@ -22,6 +23,13 @@ export function IframeTestPanel() { const iframeSkill = useIframeSkill(); const [log, setLog] = useState([]); const [open, setOpen] = useState(true); + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null, + ); + const [dragging, setDragging] = useState(false); + const panelRef = useRef(null); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const panelSizeRef = useRef({ width: 0, height: 0 }); const isSkillMode = searchParams.get("mode") === "skill"; @@ -59,16 +67,68 @@ export function IframeTestPanel() { function handleTestClipboardCopy() { const testText = "测试复制内容 - " + new Date().toISOString(); - copyToClipboard(testText); + void copyToClipboard(testText); addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`); } + function handleSendXClawUsed(used: boolean) { + sendToParent({ + type: POST_MESSAGE_TYPES.XCLAW_USED, + XClawUsed: used, + }); + addLog(`postMessage → XClawUsed (${used})`); + } + + function handlePointerDown(event: PointerEvent) { + if (!panelRef.current) return; + const rect = panelRef.current.getBoundingClientRect(); + panelSizeRef.current = { width: rect.width, height: rect.height }; + dragOffsetRef.current = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + setPosition({ x: rect.left, y: rect.top }); + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + } + + useEffect(() => { + if (!dragging) return; + const handleMove = (event: PointerEvent) => { + const { width, height } = panelSizeRef.current; + const nextX = event.clientX - dragOffsetRef.current.x; + const nextY = event.clientY - dragOffsetRef.current.y; + const clampedX = Math.min( + Math.max(8, nextX), + Math.max(8, window.innerWidth - width - 8), + ); + const clampedY = Math.min( + Math.max(8, nextY), + Math.max(8, window.innerHeight - height - 8), + ); + setPosition({ x: clampedX, y: clampedY }); + }; + const handleUp = () => { + setDragging(false); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + return () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + }; + }, [dragging]); + // 检测是否在 iframe 中 const isInIframe = typeof window !== "undefined" && window.self !== window.top; if (!open) { return ( + + + + {/* 日志 */} {log.length > 0 && (
diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 55157fee..078df2ab 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -57,6 +57,7 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { useI18n } from "@/core/i18n/hooks"; +import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages"; import { useModels } from "@/core/models/hooks"; import type { AgentThreadContext } from "@/core/threads"; import { useIframeSkill } from "@/hooks/use-iframe-skill"; @@ -211,9 +212,15 @@ export function InputBox({ return; } setIsFocused(false); + if (isNewThread) { + sendToParent({ + type: POST_MESSAGE_TYPES.XCLAW_USED, + XClawUsed: true, + }); + } onSubmit?.(message); }, - [onSubmit, onStop, status], + [isNewThread, onSubmit, onStop, status], ); const requestFormSubmit = useCallback(() => { diff --git a/frontend/src/core/iframe-messages.ts b/frontend/src/core/iframe-messages.ts index 9a28b2f1..d77adb64 100644 --- a/frontend/src/core/iframe-messages.ts +++ b/frontend/src/core/iframe-messages.ts @@ -9,6 +9,8 @@ export const POST_MESSAGE_TYPES = { // 全屏切换 FULLSCREEN: "fullscreen", + // XClaw 使用状态 + XCLAW_USED: "XClawUsed", // 选择预定义 skill SELECT_SKILL: "selectSkill", // 打开 skill 选择对话框 @@ -33,6 +35,11 @@ export interface FullscreenMessage { fullscreen: boolean; } +export interface XClawUsedMessage { + type: typeof POST_MESSAGE_TYPES.XCLAW_USED; + XClawUsed: boolean; +} + export interface SelectSkillMessage { type: typeof POST_MESSAGE_TYPES.SELECT_SKILL; skill_id: string; @@ -51,8 +58,13 @@ export interface SelectedSkillMessage { // 发送消息的辅助函数 export function sendToParent( - message: FullscreenMessage | SelectSkillMessage | OpenSkillDialogMessage, + message: + | FullscreenMessage + | XClawUsedMessage + | SelectSkillMessage + | OpenSkillDialogMessage, ): void { + console.log("[iframe] sendToParent:", message); if (window.parent !== window) { window.parent.postMessage(message, "*"); } diff --git a/frontend/src/hooks/use-iframe-skill.ts b/frontend/src/hooks/use-iframe-skill.ts index 63cd5cee..481968e4 100644 --- a/frontend/src/hooks/use-iframe-skill.ts +++ b/frontend/src/hooks/use-iframe-skill.ts @@ -1,5 +1,5 @@ -import { useSearchParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useEffect, useCallback, useRef } from "react"; import { POST_MESSAGE_TYPES, @@ -23,9 +23,13 @@ interface UseIframeSkillReturn { } export function useIframeSkill(): UseIframeSkillReturn { + const router = useRouter(); const searchParams = useSearchParams(); const skillIdFromQuery = searchParams.get("skill_id"); const titleFromQuery = searchParams.get("title"); + const threadIdFromQuery = searchParams.get("thread_id"); + const xClawUsedFromQuery = searchParams.get("XClawUsed"); + const lastThreadIdRef = useRef(null); const [selectedSkill, setSelectedSkill] = useState(null); @@ -36,6 +40,15 @@ export function useIframeSkill(): UseIframeSkillReturn { } }, [skillIdFromQuery, titleFromQuery]); + // 0. 监听 query 中 XClawUsed=true 且带 thread_id 时跳转并清理 query + useEffect(() => { + if (!threadIdFromQuery) return; + if (xClawUsedFromQuery !== "true") return; + if (lastThreadIdRef.current === threadIdFromQuery) return; + lastThreadIdRef.current = threadIdFromQuery; + router.replace(`/workspace/chats/${threadIdFromQuery}`); + }, [router, threadIdFromQuery, xClawUsedFromQuery]); + // 2. 监听宿主页 postMessage useEffect(() => { const handleMessage = (event: MessageEvent) => { diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 593623c9..fdbaba0a 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -452,4 +452,29 @@ p { .cm-line { font-size: calc(14px * var(--zoom-scale)); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.ͼ4s { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.cm-content { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.cm-scroller { + min-width: 0; +} + +.cm-editor { + overflow: hidden; + contain: paint; }