feat(ui): 重构聊天页布局并规范化iframe 通信

This commit is contained in:
肖应宇 2026-03-19 17:32:19 +08:00
parent 3a7940654c
commit cb0ebf41bb
12 changed files with 212 additions and 114 deletions

View File

@ -8,6 +8,8 @@
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev --turbo",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"preview": "next build && next start",

View File

@ -25,7 +25,7 @@ export default async function RootLayout({
return (
<html
lang={locale}
className={geist.variable}
className={geist.variable+""}
suppressContentEditableWarning
suppressHydrationWarning
>

View File

@ -16,4 +16,4 @@ export default function ChatLayout({
</ArtifactsProvider>
</SubtasksProvider>
);
}
}

View File

@ -17,11 +17,6 @@ import {
DevDialogHeader,
DevDialogTitle,
} from "@/components/ui/dev-dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
@ -257,13 +252,19 @@ export default function ChatPage() {
return (
<ThreadContext.Provider value={{ threadId, thread }}>
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
className="relative overflow-hidden rounded-[20px]"
defaultSize={artifactPanelOpen ? 46 : 100}
minSize={artifactPanelOpen ? 30 : 100}
>
<div className="relative flex size-full min-h-0 justify-between rounded-[20px]">
<div className={cn(
"m-auto flex h-screen min-h-svh overflow-hidden rounded-t-[20px] transition-[width] duration-300 ease-in-out",
artifactsOpen ? "w-full" : "w-[50%]",
)}>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<div
className={cn(
"relative overflow-hidden rounded-t-[20px] transition-all duration-300 ease-in-out",
artifactPanelOpen ? "w-[50%]" : "w-full",
fullscreen && "hidden",
)}
>
<div className="relative flex size-full min-h-0 justify-between rounded-t-[20px]">
<header
className={cn(
"bg-background absolute top-0 right-0 left-0 z-30 mx-4 grid h-[58px] shrink-0 grid-cols-3 items-center border-b transition-all duration-300 ease-in-out",
@ -294,7 +295,7 @@ export default function ChatPage() {
</svg>
</Button>
</div>
<div className="flex items-center justify-center overflow-hidden text-sm font-medium">
<div className="flex items-center justify-center whitespace-nowrap text-[#333333] font-bold overflow-hidden text-sm font-medium">
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} />
)}
@ -336,7 +337,7 @@ export default function ChatPage() {
</header>
<main
className={cn(
"flex min-h-0 max-w-full grow flex-col rounded-[20px]",
"flex min-h-0 max-w-full grow flex-col",
isNewThread && !hasSubmitted ? "bg-white" : "bg-background",
)}
>
@ -358,21 +359,13 @@ export default function ChatPage() {
</div>
</main>
</div>
</ResizablePanel>
<ResizableHandle
</div>
<div
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-background ml-[20px] rounded-[20px] transition-all duration-300 ease-in-out",
"bg-background ml-[20px] rounded-t-[20px] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
artifactPanelOpen ? (fullscreen ? "w-full ml-0" : "w-[70%]") : "w-0",
)}
defaultSize={artifactPanelOpen ? 64 : 0}
minSize={0}
maxSize={artifactPanelOpen ? undefined : 0}
>
<div
className={cn(
@ -388,7 +381,7 @@ export default function ChatPage() {
/>
) : (
<div className="relative flex size-full justify-center px-[20px]">
<div className="absolute top-1 right-1 z-30">
<div className="absolute top-2 right-2 z-30">
<Button
size="icon-sm"
variant="ghost"
@ -406,9 +399,9 @@ export default function ChatPage() {
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4">
<header className="shrink-0">
<h2 className="text-lg font-medium">
<h2 className="text-[14px] text-[#333333] font-bold">
{t.common.artifacts}
</h2>
</header>
@ -424,21 +417,21 @@ export default function ChatPage() {
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
{/* Fixed 底部居中输入框容器 */}
<div
className={cn(
"pointer-events-none fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4",
"transition-all duration-300 ease-in-out",
fullscreen ? "right-[50%]" : "",
fullscreen ? "hidden" : "",
)}
>
<div
className={cn(
"pointer-events-auto relative w-full max-w-[720px]",
isNewThread && !hasSubmitted && "top-[-65px] -translate-y-[calc(50vh-96px)]",
isNewThread && !hasSubmitted && "-translate-y-[calc(50vh-96px)]",
)}
>
<InputBox
@ -541,6 +534,7 @@ export default function ChatPage() {
{/* MARK: 开发测试iframe 通信功能测试面板 */}
{/* <IframeTestPanel /> */}
</div>
</ThreadContext.Provider>
);
}

View File

@ -76,4 +76,4 @@ export default function WorkspaceLayout({
/>
</QueryClientProvider>
);
}
}

View File

@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"bg-background flex flex-col overflow-hidden rounded-[20px] px-[20px] pt-[15px]",
"bg-background flex flex-col overflow-hidden rounded-t-[20px] px-[20px] pt-[15px]",
className,
)}
{...props}
@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"mb-[20px] grid grid-cols-3 items-center justify-between",
"mb-[10px] flex items-center justify-between",
className,
)}
{...props}

View File

@ -1,3 +1,5 @@
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@ -19,6 +21,46 @@ interface DropdownSelectorProps<T extends string> {
contentClassName?: string;
}
function ChevronDownIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 0.75L4.75 4.75L8.75 0.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronUpIcon() {
return (
<svg
width="10"
height="6"
viewBox="0 0 10 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 4.75L4.75 0.75L8.75 4.75"
stroke="#666666"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DropdownSelector<T extends string>({
value,
options,
@ -27,16 +69,20 @@ export function DropdownSelector<T extends string>({
contentClassName,
}: DropdownSelectorProps<T>) {
const selectedOption = options.find((opt) => opt.value === value);
const [isOpen, setIsOpen] = useState(false);
return (
<DropdownMenu>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger
className={
triggerClassName ??
"border-none bg-transparent shadow-none select-none focus:outline-none"
}
>
{selectedOption?.label ?? value}
<span className="flex items-center gap-1">
{selectedOption?.label ?? value}
{isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent className={contentClassName}>
<DropdownMenuRadioGroup

View File

@ -69,8 +69,8 @@ function ToggleGroupItem({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 cursor-pointer px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
"w-[50px] h-full min-w-0 shrink-0 bg-white cursor-pointer px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]",
className,
)}
{...props}

View File

@ -43,6 +43,7 @@ import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { POST_MESSAGE_TYPES, sendToParent } from "@/core/iframe-messages";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
@ -64,8 +65,7 @@ export function ArtifactFileDetail({
threadId: string;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const [fullscreen, setFullscreen] = useState(false);
const { artifacts, setOpen, select, fullscreen, setFullscreen } = useArtifacts();
const isWriteFile = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
@ -111,33 +111,14 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(100);
const [zoom, setZoom] = useState(80);
// 全屏切换处理
const handleFullscreenToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch((err) => {
console.error("无法进入全屏模式:", err);
});
setFullscreen(true);
} else {
document.exitFullscreen().catch((err) => {
console.error("无法退出全屏模式:", err);
});
setFullscreen(false);
}
}, [setFullscreen]);
// 监听全屏变化
useEffect(() => {
const handleFullscreenChange = () => {
setFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, [setFullscreen]);
const newFullscreen = !fullscreen;
setFullscreen(newFullscreen);
sendToParent({ type: POST_MESSAGE_TYPES.FULLSCREEN, fullscreen: newFullscreen });
}, [fullscreen, setFullscreen]);
useEffect(() => {
if (previewable) {
@ -175,8 +156,9 @@ export function ArtifactFileDetail({
{previewable && (
<ToggleGroup
type="single"
variant="outline"
size="sm"
variant={null}
size="default"
className="h-[28px]"
value={viewMode}
onValueChange={(value) => {
if (value) {
@ -185,13 +167,23 @@ export function ArtifactFileDetail({
}}
>
<ToggleGroupItem value="code">
<Code2Icon />
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 6L2 9L5 12" stroke="#150033" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 3L7 15" stroke="#150033" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 6L16 9L13 12" stroke="#150033" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</ToggleGroupItem>
<ToggleGroupItem value="preview">
<EyeIcon />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="10" viewBox="0 0 16 10" fill="none">
<path d="M8 0.5C10.4943 0.5 12.8473 1.84466 14.792 4.21973C15.1644 4.67466 15.1644 5.32534 14.792 5.78027C12.8473 8.15534 10.4943 9.5 8 9.5C5.50561 9.49989 3.15269 8.15543 1.20801 5.78027C0.835561 5.32534 0.835562 4.67466 1.20801 4.21973C3.15269 1.84457 5.50561 0.500106 8 0.5Z" stroke="#666666"/>
<circle cx="8" cy="5" r="1.5" stroke="#666666"/>
</svg>
</ToggleGroupItem>
</ToggleGroup>
)}
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div>
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
@ -207,8 +199,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
<div className="flex items-center justify-end overflow-hidden">
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions>
{isCodeFile && (
<ArtifactAction
@ -340,25 +331,27 @@ export function ArtifactFileDetail({
</svg>
)}
</ArtifactAction>
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{!fullscreen && (
<ArtifactAction
label={t.common.close}
onClick={() => setOpen(false)}
tooltip={t.common.close}
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 14L14 4M4 4L14 14"
stroke="#666666"
stroke-linecap="round"
/>
</svg>
</ArtifactAction>
)}
</ArtifactActions>
</div>
</ArtifactHeader>
@ -471,8 +464,7 @@ export const ArtifactZoomSelector = ({
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-[10px] bg-white px-2 py-1 backdrop-blur-sm",
"border border-gray-200/50",
"inline-flex items-center gap-1 rounded-[10px] h-[28px] bg-white backdrop-blur-sm",
"dark:border-gray-700/50 dark:bg-gray-800/90",
className,
)}
@ -483,18 +475,22 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"flex h-full w-10 py-1 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666"/>
<path d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z" fill="#666666"/>
<path d="M5.33325 7.5H9.7777M7.55547 5V10" stroke="#666666" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<span
className={cn(
"min-w-[42px] text-center text-xs font-medium text-gray-600",
"min-w-[36px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
@ -505,14 +501,18 @@ export const ArtifactZoomSelector = ({
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"flex h-full w-10 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="7.55558" cy="7.55534" r="6.16667" stroke="#666666"/>
<path d="M13.8688 15.4646C14.064 15.6598 14.3806 15.6598 14.5759 15.4646C14.7711 15.2693 14.7711 14.9527 14.5759 14.7574L14.2223 15.111L13.8688 15.4646ZM14.2223 15.111L14.5759 14.7574L11.9092 12.0908L11.5557 12.4443L11.2021 12.7979L13.8688 15.4646L14.2223 15.111Z" fill="#666666"/>
<path d="M4.99927 7.5H9.99927" stroke="#666666" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
);

View File

@ -57,7 +57,7 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
<ConversationContent className="px-[20px] w-full gap-8 pt-12">
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return (

View File

@ -0,0 +1,55 @@
/**
* iframe 宿
*
* { type: MESSAGE_TYPE, ... }
* window.parent.postMessage(message, "*")
*/
// 发送给宿主页的消息类型
export const POST_MESSAGE_TYPES = {
// 全屏切换
FULLSCREEN: "fullscreen",
// 选择预定义 skill
SELECT_SKILL: "selectSkill",
// 打开 skill 选择对话框
OPEN_SKILL_DIALOG: "openSkillDialog",
} as const;
// 接收来自宿主页的消息类型
export const RECEIVE_MESSAGE_TYPES = {
// 选中的 skill 数据
SELECTED_SKILL: "selectedSkill",
} as const;
// 消息类型
export type PostMessageType = (typeof POST_MESSAGE_TYPES)[keyof typeof POST_MESSAGE_TYPES];
export type ReceiveMessageType = (typeof RECEIVE_MESSAGE_TYPES)[keyof typeof RECEIVE_MESSAGE_TYPES];
// 消息数据类型
export interface FullscreenMessage {
type: typeof POST_MESSAGE_TYPES.FULLSCREEN;
fullscreen: boolean;
}
export interface SelectSkillMessage {
type: typeof POST_MESSAGE_TYPES.SELECT_SKILL;
skill_id: string;
}
export interface OpenSkillDialogMessage {
type: typeof POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG;
openSkillDialog: true;
}
export interface SelectedSkillMessage {
type: typeof RECEIVE_MESSAGE_TYPES.SELECTED_SKILL;
id: string | number;
title: string;
}
// 发送消息的辅助函数
export function sendToParent(message: FullscreenMessage | SelectSkillMessage | OpenSkillDialogMessage): void {
if (window.parent !== window) {
window.parent.postMessage(message, "*");
}
}

View File

@ -1,11 +1,12 @@
import { useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
// 消息类型常量
const MESSAGE_TYPES = {
SELECT_SKILL: "selectSkill",
OPEN_SKILL_DIALOG: "openSkillDialog",
} as const;
import {
POST_MESSAGE_TYPES,
RECEIVE_MESSAGE_TYPES,
sendToParent,
type SelectedSkillMessage,
} from "@/core/iframe-messages";
// Skill 数据类型
interface SkillData {
@ -38,8 +39,8 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 2. 监听宿主页 postMessage
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "selectedSkill") {
const { id, title } = event.data;
if (event.data?.type === RECEIVE_MESSAGE_TYPES.SELECTED_SKILL) {
const { id, title } = event.data as SelectedSkillMessage;
setSelectedSkill({ skill_id: String(id), title });
}
};
@ -49,28 +50,28 @@ export function useIframeSkill(): UseIframeSkillReturn {
// 发送选择预定义 skill
const sendSelectSkill = useCallback((skill_id: string) => {
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id };
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id };
console.log("[useIframeSkill] sendSelectSkill:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
// 打开 skill 选择对话框
const openSkillDialog = useCallback(() => {
const message = {
type: MESSAGE_TYPES.OPEN_SKILL_DIALOG,
type: POST_MESSAGE_TYPES.OPEN_SKILL_DIALOG,
openSkillDialog: true,
};
} as const;
console.log("[useIframeSkill] openSkillDialog:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
// 清除选中并发送 skill_id=0 给主页
const clearSkill = useCallback(() => {
setSelectedSkill(null);
// 发送 skill_id=0 给主页,通知取消选择
const message = { type: MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
const message = { type: POST_MESSAGE_TYPES.SELECT_SKILL, skill_id: "0" };
console.log("[useIframeSkill] clearSkill, sending skill_id=0:", message);
window.parent.postMessage(message, "*");
sendToParent(message);
}, []);
return { selectedSkill, sendSelectSkill, openSkillDialog, clearSkill };