feat(ui): 重构输入框附件预览与布局优化
- 将附件预览改为方形缩略图样式,图片支持悬浮遮罩和删除按钮 - 输入框宽度固定为 720px,附件预览区域移至输入框上方 - 提交按钮添加禁用状态逻辑(无内容或流式传输时禁用) - 添加 Streamdown Markdown 样式(标题、列表项字号) - 调整图标颜色、圆角和间距细节
This commit is contained in:
parent
54dd21f18b
commit
5afe834b53
|
|
@ -132,11 +132,11 @@ export default function ChatPage() {
|
|||
<div className="fixed right-0 bottom-3 left-0 z-30 flex justify-center px-4 pointer-events-none">
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full pointer-events-auto",
|
||||
isNewThread && "-translate-y-[calc(50vh-96px)]",
|
||||
isNewThread
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
"relative w-full pointer-events-auto max-w-[720px]",
|
||||
isNewThread && "-translate-y-[calc(50vh-96px)] top-[-65px]",
|
||||
// isNewThread
|
||||
// ? "max-w-(--container-width-sm)"
|
||||
// : "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
<InputBox
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export default function WorkspaceLayout({
|
|||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<WorkspaceSidebar />
|
||||
{/* TODO: !!!!必须注释!!!!! */}
|
||||
<WorkspaceSidebar className="" />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
|
|
|
|||
|
|
@ -143,8 +143,10 @@ export const ArtifactContent = ({
|
|||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className="min-h-0 flex-1 overflow-auto ">
|
||||
<div
|
||||
className={cn("min-h-0 flex-1 overflow-auto p-4", className)}
|
||||
className={cn("p-4 mb-[208px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export const ChainOfThoughtStep = memo(
|
|||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
{isValidElement(Icon) ? Icon : <Icon className="size-4" />}
|
||||
{isValidElement(Icon) ? Icon : <Icon className="size-4 text-[#999999]" />}
|
||||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -295,81 +295,81 @@ export function PromptInputAttachment({
|
|||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
const truncateFilename = (name: string, maxLen: number = 10) => {
|
||||
if (name.length <= maxLen) return name;
|
||||
const ext = name.slice(name.lastIndexOf("."));
|
||||
const baseName = name.slice(0, name.lastIndexOf("."));
|
||||
const truncated = baseName.slice(0, maxLen - ext.length - 3);
|
||||
return truncated + "..." + ext;
|
||||
};
|
||||
|
||||
return (
|
||||
<PromptInputHoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none",
|
||||
className,
|
||||
)}
|
||||
key={data.id}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-5 shrink-0">
|
||||
<div className="bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded transition-opacity group-hover:opacity-0">
|
||||
{isImage ? (
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-5 object-cover"
|
||||
height={20}
|
||||
src={data.url}
|
||||
width={20}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex size-5 items-center justify-center">
|
||||
<PaperclipIcon className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex size-16 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-lg transition-all select-none",
|
||||
isImage ? "p-0" : "bg-gray-100 dark:bg-gray-700",
|
||||
className,
|
||||
)}
|
||||
key={data.id}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
src={data.url}
|
||||
/>
|
||||
{/* 悬浮遮罩层 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ borderRadius: '10px', background: 'rgba(0, 0, 0, 0.60)' }}
|
||||
>
|
||||
{/* 眼睛图标 - 居中 */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10 4.75C13.3315 4.75 16.4669 6.61444 18.9805 9.88281C19.0335 9.95183 19.0335 10.0482 18.9805 10.1172C16.4669 13.3856 13.3315 15.25 10 15.25C6.66835 15.2499 3.53309 13.3857 1.01953 10.1172C0.966466 10.0482 0.966465 9.95182 1.01953 9.88281C3.53309 6.61435 6.66835 4.75014 10 4.75Z" stroke="white" strokeWidth="1.5"/>
|
||||
<path d="M10 7.75C11.2426 7.75 12.25 8.75736 12.25 10C12.25 11.2426 11.2426 12.25 10 12.25C8.75736 12.25 7.75 11.2426 7.75 10C7.75 8.75736 8.75736 7.75 10 7.75Z" stroke="white" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
{/* 删除按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
|
||||
className="absolute top-1.5 right-1.5 z-10 flex size-4 cursor-pointer items-center justify-center rounded-sm transition-colors hover:bg-white/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M0.75 0.75L6.74995 6.74995" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<path d="M6.75 0.75L0.750025 6.74992" stroke="white" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="flex-1 truncate">{attachmentLabel}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<PromptInputHoverCardContent className="w-auto p-2">
|
||||
<div className="w-auto space-y-3">
|
||||
{isImage && (
|
||||
<div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
|
||||
<img
|
||||
alt={filename || "attachment preview"}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
height={384}
|
||||
src={data.url}
|
||||
width={448}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="min-w-0 flex-1 space-y-1 px-0.5">
|
||||
<h4 className="truncate text-sm leading-none font-semibold">
|
||||
{filename || (isImage ? "Image" : "Attachment")}
|
||||
</h4>
|
||||
{data.mediaType && (
|
||||
<p className="text-muted-foreground truncate font-mono text-xs">
|
||||
{data.mediaType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-1">
|
||||
<PaperclipIcon className="size-6 text-gray-400" />
|
||||
<span className="max-w-full truncate text-center text-[10px] text-gray-500">
|
||||
{truncateFilename(filename)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputHoverCardContent>
|
||||
</PromptInputHoverCard>
|
||||
{/* 关闭按钮 - 右上角 */}
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-1 right-1 z-10 flex size-5 cursor-pointer items-center justify-center rounded bg-white/90 transition-colors hover:bg-white dark:bg-gray-800/90 dark:hover:bg-gray-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
attachments.remove(data.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3 text-gray-600 dark:text-gray-300" />
|
||||
<span className="sr-only">Remove</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -393,13 +393,14 @@ export function PromptInputAttachments({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2 p-3", className)}
|
||||
className={cn(
|
||||
"inline-flex flex-row flex-nowrap items-center gap-2 rounded-xl p-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.files.map((file) => (
|
||||
<Fragment key={file.id}>
|
||||
<div className="max-w-60">{children(file)}</div>
|
||||
</Fragment>
|
||||
<Fragment key={file.id}>{children(file)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1032,9 +1033,22 @@ export const PromptInputSubmit = ({
|
|||
variant = "default",
|
||||
size = "sm",
|
||||
status,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
|
||||
// 判断是否有内容可发送
|
||||
const hasContent = controller
|
||||
? controller.textInput.value.trim().length > 0 || controller.attachments.files.length > 0
|
||||
: false;
|
||||
|
||||
// 正在 streaming 时不允许发送
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
const isDisabled = disabled || !hasContent || isStreaming;
|
||||
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
|
|
@ -1049,10 +1063,17 @@ export const PromptInputSubmit = ({
|
|||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
// 被button{bgc:#fff}覆盖了,只能加"!"
|
||||
className={cn(className,'rounded-[10px] w-[140px] h-[40px] text-[#8E47F0] font-bold !bg-[#F0E8FB] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]')}
|
||||
className={cn(
|
||||
'rounded-[10px] w-[140px] h-[40px] font-bold border-0 transition-all',
|
||||
isDisabled
|
||||
? 'text-gray-400 !bg-gray-200 cursor-not-allowed'
|
||||
: 'text-[#8E47F0] !bg-[#F0E8FB] hover:!bg-[#8E47F0] hover:text-[#FFFFFF]',
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ export function ArtifactFileDetail({
|
|||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent className="p-0 rounded-[20px] bg-white">
|
||||
|
||||
<ArtifactContent className="p-0 rounded-[10px] bg-white">
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
|
|
@ -247,7 +248,6 @@ export function ArtifactFileDetail({
|
|||
src={urlOfArtifact({ filepath, threadId, isMock })}
|
||||
/>
|
||||
)}
|
||||
{/* <div style={{ height: `${paddingBottom}px` }} /> */}
|
||||
</ArtifactContent>
|
||||
</Artifact>
|
||||
);
|
||||
|
|
@ -262,7 +262,7 @@ export function ArtifactFilePreview({
|
|||
}) {
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div className={cn("size-full px-4")}>
|
||||
<div className={cn("size-full p-[20px]")}>
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,10 @@ export function InputBox({
|
|||
const { models } = useModels();
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const iframeSkill = useIframeSkill();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
|
|
@ -398,10 +400,13 @@ export function InputBox({
|
|||
|
||||
return (
|
||||
<div ref={(el) => { promptRootRef.current = el; containerRef.current = el; }} className="relative">
|
||||
{/* 附件预览区域 - 在输入框上方 */}
|
||||
<AttachmentPreviewBar />
|
||||
|
||||
{extraHeader && (
|
||||
<div className="absolute right-0 bottom-full left-0 z-30 flex items-center justify-center pb-4">
|
||||
<ExtraHeaderContainer hasAttachments={attachments.files.length > 0}>
|
||||
{extraHeader}
|
||||
</div>
|
||||
</ExtraHeaderContainer>
|
||||
)}
|
||||
{/* 输入框主容器 */}
|
||||
<PromptInput
|
||||
|
|
@ -410,10 +415,10 @@ export function InputBox({
|
|||
className,
|
||||
)}
|
||||
inputGroupClassName={cn(
|
||||
" backdrop-blur-sm rounded-[20px]",
|
||||
"border-0 backdrop-blur-sm w-[720px] rounded-[20px]",
|
||||
"transition-[height] duration-300 ease-out",
|
||||
!isNewThread && "shadow-[0_0_20px_2px_rgba(0,0,0,0.10)]",
|
||||
effectiveIsFocused ? "h-[200px]" : "h-12",
|
||||
effectiveIsFocused ? "h-[200px]" : "h-[80px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
globalDrop
|
||||
|
|
@ -421,16 +426,16 @@ export function InputBox({
|
|||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
>
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
<PromptInputBody className={cn(
|
||||
"transition-[opacity,transform] duration-300 ease-out",
|
||||
!effectiveIsFocused && "opacity-0 pointer-events-none"
|
||||
!effectiveIsFocused && "opacity-100"
|
||||
)}>
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
className={cn("size-full")}
|
||||
className={cn(
|
||||
"size-full",
|
||||
!effectiveIsFocused && "h-[80px] py-0 leading-20"
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
autoFocus={autoFocus}
|
||||
|
|
@ -499,7 +504,12 @@ export function InputBox({
|
|||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide> */}
|
||||
{/* Skill 选择按钮 (iframe 与宿主页通信) */}
|
||||
<IframeSkillDialogButton className="px-2!"/>
|
||||
<IframeSkillDialogButton
|
||||
className="px-2!"
|
||||
selectedSkill={iframeSkill.selectedSkill}
|
||||
openSkillDialog={iframeSkill.openSkillDialog}
|
||||
clearSkill={iframeSkill.clearSkill}
|
||||
/>
|
||||
{/* [已禁用] 模式选择下拉菜单内容 */}
|
||||
{/* <PromptInputActionMenuContent className="w-80">
|
||||
<DropdownMenuGroup>
|
||||
|
|
@ -785,7 +795,7 @@ export function InputBox({
|
|||
</PromptInputFooter>
|
||||
{/* 移动出来 */}
|
||||
<PromptInputSubmit
|
||||
className="absolute right-3 bottom-3 z-[20] border-0"
|
||||
className="absolute right-3 bottom-5 z-[20] border-0"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
|
|
@ -797,9 +807,7 @@ export function InputBox({
|
|||
</PromptInput>
|
||||
{/* 小惊喜等 */}
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && (
|
||||
<div className="absolute right-0 bottom-0 left-0 z-0 flex items-center justify-center translate-y-full pt-4">
|
||||
<SuggestionList />
|
||||
</div>
|
||||
<SuggestionListContainer sendSelectSkill={iframeSkill.sendSelectSkill} />
|
||||
)}
|
||||
|
||||
{!disabled &&
|
||||
|
|
@ -861,11 +869,19 @@ export function InputBox({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
// SuggestionList 容器
|
||||
function SuggestionListContainer({ sendSelectSkill }: { sendSelectSkill: (skill_id: string) => void }) {
|
||||
return (
|
||||
<div className="absolute right-0 bottom-0 left-0 z-0 flex items-center justify-center pt-4 translate-y-full">
|
||||
<SuggestionList sendSelectSkill={sendSelectSkill} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 快速选择skillbutton
|
||||
function SuggestionList() {
|
||||
function SuggestionList({ sendSelectSkill }: { sendSelectSkill: (skill_id: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const { textInput } = usePromptInputController();
|
||||
const { sendSelectSkill } = useIframeSkill();
|
||||
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||||
|
|
@ -958,9 +974,18 @@ function AddAttachmentsButton({ className }: { className?: string }) {
|
|||
);
|
||||
}
|
||||
// 启动iframeSkillDialog
|
||||
function IframeSkillDialogButton({ className }: { className?: string }) {
|
||||
function IframeSkillDialogButton({
|
||||
className,
|
||||
selectedSkill,
|
||||
openSkillDialog,
|
||||
clearSkill,
|
||||
}: {
|
||||
className?: string;
|
||||
selectedSkill: { skill_id: string; title: string } | null;
|
||||
openSkillDialog: () => void;
|
||||
clearSkill: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { selectedSkill, openSkillDialog, clearSkill } = useIframeSkill();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -988,3 +1013,38 @@ function IframeSkillDialogButton({ className }: { className?: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 附件预览栏 - 在输入框上方显示
|
||||
function AttachmentPreviewBar() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
if (!attachments.files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 ml-1 z-20 mb-3 flex justify-start">
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ExtraHeader 容器 - 有附件时上浮
|
||||
function ExtraHeaderContainer({
|
||||
hasAttachments,
|
||||
children
|
||||
}: {
|
||||
hasAttachments: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute right-0 bottom-full left-0 z-30 flex items-center justify-center pb-4 transition-transform duration-300",
|
||||
hasAttachments && "-translate-y-20"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export function MessageGroup({
|
|||
<ChainOfThoughtStep
|
||||
className="font-normal"
|
||||
label={t.common.thinking}
|
||||
icon={LightbulbIcon}
|
||||
icon={<LightbulbIcon className="size-4" style={{ color: '#999999' }} />}
|
||||
></ChainOfThoughtStep>
|
||||
<div>
|
||||
<ChevronUp
|
||||
|
|
|
|||
|
|
@ -409,3 +409,29 @@
|
|||
--container-width-md: calc(var(--spacing) * 204);
|
||||
--container-width-lg: calc(var(--spacing) * 256);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Streamdown Markdown Styles
|
||||
使用 data-streamdown 属性选择器统一定义
|
||||
======================================== */
|
||||
/* p标签没有标识data-streamdown,暂时只能这么写 */
|
||||
p{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* 列表项 - 14px */
|
||||
[data-streamdown="list-item"] {
|
||||
font-size: 14px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
/* 一级标题 - 20px */
|
||||
|
||||
[data-streamdown="heading-1"]{
|
||||
font-size: 20px;
|
||||
}
|
||||
/* 二三级标题 - 16px */
|
||||
[data-streamdown="heading-2"],[data-streamdown="heading-3"] {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue