feat():detail界面换行;监听XClawUsed,如果有就用thread_id替换嵌入页路由;

This commit is contained in:
肖应宇 2026-03-30 17:15:51 +08:00
parent 724a5aca31
commit 7fe6d57316
6 changed files with 172 additions and 11 deletions

View File

@ -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() {
</DevDialogContent>
</DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* <IframeTestPanel /> */}
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{process.env.NODE_ENV !== "production" && <IframeTestPanel />}
</div>
</ThreadContext.Provider>
);

View File

@ -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<string[]>([]);
const [open, setOpen] = useState(true);
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null,
);
const [dragging, setDragging] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) {
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 (
<button
className="fixed bottom-24 left-3 z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600"
className={cn(
"fixed z-[9999] rounded-full bg-violet-500 px-3 py-1 text-xs font-bold text-white shadow-lg hover:bg-violet-600",
position ? "left-0 top-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
onClick={() => setOpen(true)}
>
🧪
@ -76,9 +136,22 @@ export function IframeTestPanel() {
);
}
return (
<div className="fixed bottom-24 left-3 z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm">
<div
ref={panelRef}
className={cn(
"fixed z-[9999] w-72 rounded-xl border border-violet-200 bg-white/95 shadow-2xl backdrop-blur-sm",
position ? "left-0 top-0" : "bottom-24 left-3",
)}
style={position ? { left: position.x, top: position.y } : undefined}
>
{/* 标题栏 */}
<div className="flex items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2">
<div
className={cn(
"flex cursor-grab items-center justify-between rounded-t-xl bg-violet-500 px-3 py-2 select-none",
dragging && "cursor-grabbing",
)}
onPointerDown={handlePointerDown}
>
<span className="text-xs font-bold text-white">🧪 iframe </span>
<button
className="text-white/70 hover:text-white"
@ -248,6 +321,31 @@ export function IframeTestPanel() {
</div>
</div>
{/* 场景 5XClaw 使用状态 */}
<div>
<div className="mb-1 text-xs font-semibold text-gray-500">
XClaw 使
</div>
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-100"
variant="ghost"
onClick={() => handleSendXClawUsed(true)}
>
used=true
</Button>
<Button
size="sm"
className="flex-1 bg-slate-50 text-xs text-slate-700 hover:bg-slate-100"
variant="ghost"
onClick={() => handleSendXClawUsed(false)}
>
used=false
</Button>
</div>
</div>
{/* 日志 */}
{log.length > 0 && (
<div className="rounded-lg bg-gray-900 p-2">

View File

@ -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(() => {

View File

@ -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, "*");
}

View File

@ -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<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(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) => {

View File

@ -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;
}