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"; } from "@/components/workspace/artifacts";
import { useThreadChat } from "@/components/workspace/chats"; import { useThreadChat } from "@/components/workspace/chats";
import { DevTodoList } from "@/components/workspace/dev-todo-list"; import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { IframeTestPanel } from "@/components/workspace/iframe-test-panel";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; 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 { useSpecificChatMode } from "@/components/workspace/use-chat-mode";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
@ -412,6 +414,10 @@ export default function ChatPage() {
await handleStop(); await handleStop();
} }
setShowExitDialog(false); setShowExitDialog(false);
sendToParent({
type: POST_MESSAGE_TYPES.XCLAW_USED,
XClawUsed: false,
});
// 使用完整页面刷新确保组件重新挂载isNewThread 为 true // 使用完整页面刷新确保组件重新挂载isNewThread 为 true
window.location.href = "/workspace/chats/new"; window.location.href = "/workspace/chats/new";
}} }}
@ -450,8 +456,8 @@ export default function ChatPage() {
</DevDialogContent> </DevDialogContent>
</DevDialog> </DevDialog>
{/* MARK: 开发测试iframe 通信功能测试面板 */} {/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* <IframeTestPanel /> */} {process.env.NODE_ENV !== "production" && <IframeTestPanel />}
</div> </div>
</ThreadContext.Provider> </ThreadContext.Provider>
); );

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import { useSearchParams, useRouter } from "next/navigation"; 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 { Button } from "@/components/ui/button";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
import { copyToClipboard } from "@/lib/utils"; import { copyToClipboard } from "@/lib/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -22,6 +23,13 @@ export function IframeTestPanel() {
const iframeSkill = useIframeSkill(); const iframeSkill = useIframeSkill();
const [log, setLog] = useState<string[]>([]); const [log, setLog] = useState<string[]>([]);
const [open, setOpen] = useState(true); 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"; const isSkillMode = searchParams.get("mode") === "skill";
@ -59,16 +67,68 @@ export function IframeTestPanel() {
function handleTestClipboardCopy() { function handleTestClipboardCopy() {
const testText = "测试复制内容 - " + new Date().toISOString(); const testText = "测试复制内容 - " + new Date().toISOString();
copyToClipboard(testText); void copyToClipboard(testText);
addLog(`copyToClipboard → "${testText.slice(0, 30)}..."`); 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 中 // 检测是否在 iframe 中
const isInIframe = typeof window !== "undefined" && window.self !== window.top; const isInIframe = typeof window !== "undefined" && window.self !== window.top;
if (!open) { if (!open) {
return ( return (
<button <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)} onClick={() => setOpen(true)}
> >
🧪 🧪
@ -76,9 +136,22 @@ export function IframeTestPanel() {
); );
} }
return ( 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> <span className="text-xs font-bold text-white">🧪 iframe </span>
<button <button
className="text-white/70 hover:text-white" className="text-white/70 hover:text-white"
@ -248,6 +321,31 @@ export function IframeTestPanel() {
</div> </div>
</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 && ( {log.length > 0 && (
<div className="rounded-lg bg-gray-900 p-2"> <div className="rounded-lg bg-gray-900 p-2">

View File

@ -57,6 +57,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { useIframeSkill } from "@/hooks/use-iframe-skill"; import { useIframeSkill } from "@/hooks/use-iframe-skill";
@ -211,9 +212,15 @@ export function InputBox({
return; return;
} }
setIsFocused(false); setIsFocused(false);
if (isNewThread) {
sendToParent({
type: POST_MESSAGE_TYPES.XCLAW_USED,
XClawUsed: true,
});
}
onSubmit?.(message); onSubmit?.(message);
}, },
[onSubmit, onStop, status], [isNewThread, onSubmit, onStop, status],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {

View File

@ -9,6 +9,8 @@
export const POST_MESSAGE_TYPES = { export const POST_MESSAGE_TYPES = {
// 全屏切换 // 全屏切换
FULLSCREEN: "fullscreen", FULLSCREEN: "fullscreen",
// XClaw 使用状态
XCLAW_USED: "XClawUsed",
// 选择预定义 skill // 选择预定义 skill
SELECT_SKILL: "selectSkill", SELECT_SKILL: "selectSkill",
// 打开 skill 选择对话框 // 打开 skill 选择对话框
@ -33,6 +35,11 @@ export interface FullscreenMessage {
fullscreen: boolean; fullscreen: boolean;
} }
export interface XClawUsedMessage {
type: typeof POST_MESSAGE_TYPES.XCLAW_USED;
XClawUsed: boolean;
}
export interface SelectSkillMessage { export interface SelectSkillMessage {
type: typeof POST_MESSAGE_TYPES.SELECT_SKILL; type: typeof POST_MESSAGE_TYPES.SELECT_SKILL;
skill_id: string; skill_id: string;
@ -51,8 +58,13 @@ export interface SelectedSkillMessage {
// 发送消息的辅助函数 // 发送消息的辅助函数
export function sendToParent( export function sendToParent(
message: FullscreenMessage | SelectSkillMessage | OpenSkillDialogMessage, message:
| FullscreenMessage
| XClawUsedMessage
| SelectSkillMessage
| OpenSkillDialogMessage,
): void { ): void {
console.log("[iframe] sendToParent:", message);
if (window.parent !== window) { if (window.parent !== window) {
window.parent.postMessage(message, "*"); window.parent.postMessage(message, "*");
} }

View File

@ -1,5 +1,5 @@
import { useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { import {
POST_MESSAGE_TYPES, POST_MESSAGE_TYPES,
@ -23,9 +23,13 @@ interface UseIframeSkillReturn {
} }
export function useIframeSkill(): UseIframeSkillReturn { export function useIframeSkill(): UseIframeSkillReturn {
const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const skillIdFromQuery = searchParams.get("skill_id"); const skillIdFromQuery = searchParams.get("skill_id");
const titleFromQuery = searchParams.get("title"); 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); const [selectedSkill, setSelectedSkill] = useState<SkillData | null>(null);
@ -36,6 +40,15 @@ export function useIframeSkill(): UseIframeSkillReturn {
} }
}, [skillIdFromQuery, titleFromQuery]); }, [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 // 2. 监听宿主页 postMessage
useEffect(() => { useEffect(() => {
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {

View File

@ -452,4 +452,29 @@ p {
.cm-line { .cm-line {
font-size: calc(14px * var(--zoom-scale)); 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;
} }