feat: 优化聊天界面UI与交互体验

- 新增退出确认对话框,点击返回时提示用户保存
- Artifact 面板改用 DropdownMenu 选择文件,优化头部布局
- 添加微软雅黑字体支持,调整主题配色
- Todo 按钮添加 Tooltip 提示
- 调整面板分割比例为 50/50,优化样式细节
This commit is contained in:
肖应宇 2026-03-15 15:07:19 +08:00
parent 1fd5405f42
commit 4897a4da58
14 changed files with 237 additions and 60 deletions

View File

@ -1,9 +1,17 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { ListTodoIcon } from "lucide-react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import {
DevDialog,
DevDialogContent,
DevDialogFooter,
DevDialogHeader,
DevDialogTitle,
} from "@/components/ui/dev-dialog";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
@ -11,6 +19,7 @@ import {
useThreadChat,
} from "@/components/workspace/chats";
import { DevTodoList } from "@/components/workspace/dev-todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
@ -27,6 +36,7 @@ import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const [showExitDialog, setShowExitDialog] = useState(false);
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
useSpecificChatMode();
@ -73,7 +83,7 @@ export default function ChatPage() {
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 px-[20px] bg-background justify-between">
<div className="relative flex size-full min-h-0 bg-background justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 grid grid-cols-3 h-14 px-[20px] py-[20px] shrink-0 items-center rounded-t-[20px]",
@ -84,25 +94,29 @@ export default function ChatPage() {
>
{/* 返回查看结果左箭头 */}
<div className=" w-full items-center h-[18px] text-sm font-medium">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14" stroke="#666666" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<button onClick={() => setShowExitDialog(true)}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 10H13.25H15.6875H16.5M3.5 10L7.5625 6M3.5 10L7.5625 14" stroke="#666666" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
<div className=" w-full text-center items-center h-[18px] text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="flex justify-end items-center gap-2">
<DevTodoList
<DevTodoList
className="bg-white"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
trigger={
<Button size="sm" variant="ghost" className="text-sm font-medium h-[18px]">
Todo
</Button>
<Tooltip content="Show Todo">
<Button size="sm" variant="ghost" className="text-sm font-medium h-[18px]">
<ListTodoIcon className="size-4" /> Todo
</Button>
</Tooltip>
}
/>
<ArtifactTrigger />
@ -151,6 +165,24 @@ export default function ChatPage() {
</div>
</div>
</ChatBox>
{/* 退出确认对话框 */}
<DevDialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DevDialogContent>
<DevDialogHeader>
<DevDialogTitle></DevDialogTitle>
</DevDialogHeader>
<p className="text-muted-foreground text-sm">
退
</p>
<DevDialogFooter >
<Button className="w-full " variant="outline" onClick={() => setShowExitDialog(false)}>
</Button>
<Button className="w-full bg-[#8E47F0] hover:!bg-[#8E47F0]" onClick={() => setShowExitDialog(false)}></Button>
</DevDialogFooter>
</DevDialogContent>
</DevDialog>
</ThreadContext.Provider>
);
}

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-lg border shadow-lg",
"bg-background flex flex-col overflow-hidden rounded-[20px] pt-[15px] px-[20px]",
className,
)}
{...props}
@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"bg-muted/50 flex items-center justify-between border-b px-4 py-3",
"grid grid-cols-3 items-center mb-[20px] justify-between",
className,
)}
{...props}

View File

@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6",
className
)}
{...props}

View File

@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DevDialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dev-dialog" {...props} />
}
function DevDialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dev-dialog-trigger" {...props} />
}
function DevDialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dev-dialog-portal" {...props} />
}
function DevDialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dev-dialog-close" {...props} />
}
function DevDialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dev-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DevDialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DevDialogPortal data-slot="dev-dialog-portal">
<DevDialogOverlay />
<DialogPrimitive.Content
data-slot="dev-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-[400px] max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-[40px] shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dev-dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DevDialogPortal>
)
}
function DevDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dev-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DevDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dev-dialog-footer"
className={cn(
// sm:justify-end
"grid grid-cols-2 w-full gap-[30px] justify-between sm:flex-row ",
className
)}
{...props}
/>
)
}
function DevDialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dev-dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DevDialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dev-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
DevDialog,
DevDialogClose,
DevDialogContent,
DevDialogDescription,
DevDialogFooter,
DevDialogHeader,
DevDialogOverlay,
DevDialogPortal,
DevDialogTitle,
DevDialogTrigger,
}

View File

@ -39,7 +39,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}

View File

@ -20,13 +20,13 @@ import {
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
SelectGroup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks";
@ -125,33 +125,10 @@ export function ArtifactFileDetail({
}, [threadId, filepath, isInstalling]);
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="px-2">
<div className="flex items-center gap-2">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<Select value={filepath} onValueChange={select}>
<SelectTrigger className="border-none bg-transparent! shadow-none select-none focus:outline-0 active:outline-0">
<SelectValue placeholder="Select a file" />
</SelectTrigger>
<SelectContent className="select-none">
<SelectGroup>
{(artifacts ?? []).map((filepath) => (
<SelectItem key={filepath} value={filepath}>
{getFileName(filepath)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactHeader>
<div className="flex items-center gap-2 justify-start">
{isSupportPreview && (
<ToggleGroup
className="mx-auto"
type="single"
variant="outline"
size="sm"
@ -171,7 +148,29 @@ export function ArtifactFileDetail({
</ToggleGroup>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex min-w-0 grow items-center justify-center">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger className="border-none bg-transparent shadow-none select-none focus:outline-none">
{getFileName(filepath)}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={filepath} onValueChange={select}>
{(artifacts ?? []).map((artifactPath) => (
<DropdownMenuRadioItem key={artifactPath} value={artifactPath}>
{getFileName(artifactPath)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</ArtifactTitle>
</div>
<div className="flex justify-end items-center gap-2">
<ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
@ -234,7 +233,7 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent className="p-0">
<ArtifactContent className="p-0 rounded-[20px] bg-white">
{isSupportPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (

View File

@ -16,7 +16,7 @@ export const ArtifactTrigger = () => {
return (
<Tooltip content="Show artifacts of this conversation">
<Button
className="text-muted-foreground hover:text-foreground"
className="text-sm font-medium h-[18px]"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);

View File

@ -20,7 +20,7 @@ import {
import { useThread } from "../messages/context";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const OPEN_MODE = { chat: 50, artifacts: 50 };
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
@ -118,7 +118,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
>
<div
className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out bg-background rounded-t-[20px]",
"h-full transition-transform duration-300 ease-in-out bg-background rounded-t-[20px]",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>

View File

@ -1,6 +1,5 @@
"use client";
import { ListTodoIcon } from "lucide-react";
import type { Todo } from "@/core/todos";
@ -57,5 +56,3 @@ export function DevTodoList({
</DropdownMenu>
);
}
export { ListTodoIcon };

View File

@ -759,9 +759,10 @@ export function InputBox({
<SuggestionList />
</div>
)}
{!isNewThread && (
{/* TODO: 神秘空div */}
{/* {!isNewThread && (
<div className="bg-background absolute right-0 -bottom-[17px] left-0 z-0 h-4"></div>
)}
)} */}
</PromptInput>
{!disabled &&

View File

@ -79,7 +79,7 @@ export function MessageGroup({
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<ChainOfThought
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
className={cn("w-full gap-2 rounded-lg border", className)}
open={true}
>
{aboveLastToolCallSteps.length > 0 && (

View File

@ -199,6 +199,7 @@ export function MessageList({
);
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
</Conversation>
);

View File

@ -30,7 +30,7 @@ export const zhCN: Translations = {
search: "搜索",
download: "下载",
thinking: "思考",
artifacts: "文件",
artifacts: "查看结果",
public: "公共",
custom: "自定义",
notAvailableInDemoMode: "在演示模式下不可用",

View File

@ -72,7 +72,7 @@
@theme {
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Microsoft YaHei", "微软雅黑", var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-fade-in: fade-in 1.1s;
@ -226,17 +226,20 @@
--radius: 0.625rem;
--background: #F9F8FA;
--foreground: oklch(0.145 0 0);
--card: oklch(1 0.0098 87.47);
/* --card: oklch(1 0.0098 87.47); */
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0.0098 87.47);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.9455 0.0098 87.47);
/* --secondary: oklch(0.9455 0.0098 87.47); */
--secondary: #1500331A;
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0.0098 87.47);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.94 0.0098 87.47);
/* --accent: oklch(0.94 0.0098 87.47); */
--accent: #1500331A;
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.0098 87.47);