Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/main/window-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export class WindowManager {
// Track if mouse events are forcibly ignored
private forceIgnoreMouse = false;

private getDefaultWindowSize(): { width: number; height: number } {
const { width, height } = screen.getPrimaryDisplay().workArea;
return {
width: Math.round(width * 0.5),
height: Math.round(height * 0.6),
};
}

constructor() {
ipcMain.on('renderer-ready-for-mode-change', (_event, newMode) => {
if (newMode === 'pet') {
Expand Down Expand Up @@ -54,9 +62,10 @@ export class WindowManager {
}

createWindow(options: Electron.BrowserWindowConstructorOptions): BrowserWindow {
const { width, height } = this.getDefaultWindowSize();
this.window = new BrowserWindow({
width: 900,
height: 670,
width,
height,
show: false,
transparent: true,
backgroundColor: '#ffffff',
Expand Down Expand Up @@ -168,7 +177,8 @@ export class WindowManager {
if (this.windowedBounds) {
this.window.setBounds(this.windowedBounds);
} else {
this.window.setSize(900, 670);
const { width, height } = this.getDefaultWindowSize();
this.window.setSize(width, height);
this.window.center();
}

Expand Down
10 changes: 7 additions & 3 deletions src/renderer/src/components/footer/footer-styles.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SystemStyleObject } from '@chakra-ui/react';

interface FooterStyles {
container: (isCollapsed: boolean) => SystemStyleObject
container: (isCollapsed: boolean, hasAttachments: boolean) => SystemStyleObject
toggleButton: SystemStyleObject
actionButton: SystemStyleObject
input: SystemStyleObject
Expand All @@ -18,13 +18,17 @@ export const footerStyles: {
aiIndicator: AIIndicatorStyles
} = {
footer: {
container: (isCollapsed) => ({
container: (isCollapsed, hasAttachments) => ({
bg: isCollapsed ? 'transparent' : 'gray.800',
borderTopRadius: isCollapsed ? 'none' : 'lg',
transform: isCollapsed ? 'translateY(calc(100% - 24px))' : 'translateY(0)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
height: '100%',
height: 'auto',
minHeight: isCollapsed ? 'auto' : { base: '100px', md: '120px' },
position: 'relative',
bottom: !isCollapsed && hasAttachments
? 'clamp(1vh, calc(110px - 5vh), 1000vh)'
: '0px',
overflow: isCollapsed ? 'visible' : 'hidden',
pb: '4',
}),
Expand Down
216 changes: 187 additions & 29 deletions src/renderer/src/components/footer/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
/* eslint-disable react/require-default-props */
import {
Box, Textarea, IconButton, HStack,
Box,
Textarea,
IconButton,
HStack,
Image,
} from '@chakra-ui/react';
import { BsMicFill, BsMicMuteFill, BsPaperclip } from 'react-icons/bs';
import { BsMicFill, BsMicMuteFill, BsPaperclip, BsX } from 'react-icons/bs';
import { IoHandRightSharp } from 'react-icons/io5';
import { FiChevronDown } from 'react-icons/fi';
import { memo } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InputGroup } from '@/components/ui/input-group';
import {
DialogRoot,
DialogContent,
DialogCloseTrigger,
DialogBody,
} from '@/components/ui/dialog';
import { footerStyles } from './footer-styles';
import AIStateIndicator from './ai-state-indicator';
import { useFooter } from '@/hooks/footer/use-footer';

// Type definitions
const MIN_THUMBNAIL_SIZE = 32;
const MAX_THUMBNAIL_SIZE = 72;
const THUMBNAIL_GAP = 8;
const REMOVE_BUTTON_RATIO = 0.34;
const MAX_ATTACHMENTS = 20;

interface FooterProps {
isCollapsed?: boolean
onToggle?: () => void
Expand All @@ -35,9 +50,10 @@ interface MessageInputProps {
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart: () => void
onCompositionEnd: () => void
onAttachFiles: (files: FileList | null) => void
attachedCount: number
}

// Reusable components
const ToggleButton = memo(({ isCollapsed, onToggle }: ToggleButtonProps) => (
<Box
{...footerStyles.footer.toggleButton}
Expand Down Expand Up @@ -81,55 +97,172 @@ const MessageInput = memo(({
onKeyDown,
onCompositionStart,
onCompositionEnd,
onAttachFiles,
attachedCount,
}: MessageInputProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement | null>(null);

return (
<InputGroup flex={1}>
<Box position="relative" width="100%">
<IconButton
aria-label="Attach file"
variant="ghost"
{...footerStyles.footer.attachButton}
>
<BsPaperclip size="24" />
</IconButton>
<Textarea
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder={t('footer.typeYourMessage')}
{...footerStyles.footer.input}
/>
</Box>
</InputGroup>
<Box flex={1} minW="clamp(280px, 42vw, 760px)" display="flex" flexDirection="column" gap="2">
<InputGroup>
<Box position="relative" width="100%">
<IconButton
aria-label="Attach file"
variant="ghost"
{...footerStyles.footer.attachButton}
onClick={() => fileInputRef.current?.click()}
>
<BsPaperclip size="24" />
</IconButton>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={(event) => {
onAttachFiles(event.target.files);
event.target.value = '';
}}
aria-label={t('footer.attachFile')}
/>
<Textarea
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder={t('footer.typeYourMessage')}
{...footerStyles.footer.input}
/>
{attachedCount > 0 && (
<Box
position="absolute"
top="2"
right="2"
fontSize="xs"
color="whiteAlpha.700"
>
{t('footer.attachmentsCount', { count: attachedCount, max: MAX_ATTACHMENTS })}
</Box>
)}
</Box>
</InputGroup>
</Box>
);
});

MessageInput.displayName = 'MessageInput';

// Main component
function Footer({ isCollapsed = false, onToggle }: FooterProps): JSX.Element {
const { t } = useTranslation();
const {
inputValue,
handleInputChange,
handleKeyPress,
handleCompositionStart,
handleCompositionEnd,
handleAttachFiles,
attachedImages,
handleRemoveAttachment,
handleInterrupt,
handleMicToggle,
micOn,
} = useFooter();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(() => (typeof window !== 'undefined' ? window.innerWidth : 1280));

useEffect(() => {
if (typeof window === 'undefined') return undefined;
const handleResize = () => setViewportWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

const thumbnailSize = useMemo(() => {
const count = Math.max(attachedImages.length, 1);
const minimumInputWidth = Math.max(320, Math.floor(viewportWidth * 0.28));
const estimatedRailWidth = Math.max(240, viewportWidth - minimumInputWidth - 300);
const availableWidth = Math.max(estimatedRailWidth, 180);
const totalGapWidth = THUMBNAIL_GAP * (count - 1);
const size = Math.floor((availableWidth - totalGapWidth) / count);
return Math.min(MAX_THUMBNAIL_SIZE, Math.max(MIN_THUMBNAIL_SIZE, size));
}, [attachedImages.length, viewportWidth]);

const removeButtonSize = Math.max(14, Math.floor(thumbnailSize * REMOVE_BUTTON_RATIO));

return (
<Box {...footerStyles.footer.container(isCollapsed)}>
<Box {...footerStyles.footer.container(isCollapsed, attachedImages.length > 0)}>
<ToggleButton isCollapsed={isCollapsed} onToggle={onToggle} />

<Box pt="0" px="4">
<HStack width="100%" gap={4}>
<Box>
{attachedImages.length > 0 && (
<Box
width="100%"
minW="0"
bg="gray.700"
borderRadius="12px"
px="3"
py="2"
mb="3"
overflowX="hidden"
>
<HStack gap={`${THUMBNAIL_GAP}px`} flexWrap="nowrap" align="center">
{attachedImages.map((image, index) => (
<Box
key={`${image.data}-${index}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在 React 中,使用列表的索引 index 或者不稳定的数据(如这里的 base64 字符串 image.data)作为 key 是一种反模式,尤其是在列表项可以被增删的情况下。这可能会导致渲染问题和性能下降。

建议在上传图片时为每个图片生成一个唯一的客户端 ID,并用它作为 key

你可以在 src/renderer/src/hooks/footer/use-text-input.tsx 中做如下修改:

  1. 更新 attachedImages 的 state 类型,使其包含一个客户端 ID:

    const [attachedImages, setAttachedImages] = useState<(ImagePayload & { clientId: string })[]>([]);
  2. handleAttachFiles 函数中,当图片被读取时,为其生成一个唯一的 ID:

    // ...
    try {
      const dataUrl = await readFileAsDataUrl(file);
      newImages.push({
        clientId: crypto.randomUUID(), // 生成唯一 ID
        source: 'upload',
        data: dataUrl,
        mime_type: file.type || 'image/*',
      });
    } // ...
  3. 然后在这里,你就可以使用这个稳定的 clientId 作为 key

Suggested change
key={`${image.data}-${index}`}
key={image.clientId}

position="relative"
borderRadius="md"
overflow="hidden"
border="1px solid"
borderColor="whiteAlpha.300"
cursor="zoom-in"
role="button"
tabIndex={0}
flex={`0 0 ${thumbnailSize}px`}
onClick={() => setPreviewImage(image.data)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setPreviewImage(image.data);
}
}}
>
<Image
src={image.data}
alt={t('footer.attachFile')}
boxSize={`${thumbnailSize}px`}
objectFit="cover"
/>
<IconButton
aria-label={t('footer.removeAttachment')}
w={`${removeButtonSize}px`}
h={`${removeButtonSize}px`}
minW={`${removeButtonSize}px`}
p="0"
fontSize={`${Math.max(10, Math.floor(removeButtonSize * 0.66))}px`}
position="absolute"
top="1"
right="1"
borderRadius="full"
bg="blackAlpha.700"
color="whiteAlpha.900"
_hover={{ bg: 'blackAlpha.800' }}
onClick={(event) => {
event.stopPropagation();
handleRemoveAttachment(index);
}}
>
<BsX />
</IconButton>
</Box>
))}
</HStack>
</Box>
)}
<HStack width="100%" gap={4} align="flex-start">
<Box flexShrink={0}>
<Box mb="1.5">
<AIStateIndicator />
</Box>
Expand All @@ -146,9 +279,34 @@ function Footer({ isCollapsed = false, onToggle }: FooterProps): JSX.Element {
onKeyDown={handleKeyPress}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onAttachFiles={handleAttachFiles}
attachedCount={attachedImages.length}
/>
</HStack>
</Box>
<DialogRoot
open={Boolean(previewImage)}
onOpenChange={(details) => {
if (!details.open) {
setPreviewImage(null);
}
}}
>
<DialogContent bg="gray.900" maxW="80vw" w="fit-content">
<DialogCloseTrigger />
<DialogBody p="4">
{previewImage && (
<Image
src={previewImage}
alt={t('footer.previewAttachment')}
maxH="80vh"
maxW="80vw"
objectFit="contain"
/>
)}
</DialogBody>
</DialogContent>
</DialogRoot>
Comment on lines +287 to +309

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

图片预览的 Dialog 组件在这两个文件中几乎完全一样:

  • src/renderer/src/components/footer/footer.tsx
  • src/renderer/src/components/sidebar/chat-history-panel.tsx

为了遵循 DRY (Don't Repeat Yourself) 原则并提高代码的可维护性,建议将这个 Dialog 提取到一个独立的可复用组件中,例如 ImagePreviewDialog

这个新组件可以接收 open, onOpenChange, 和 imageUrl 作为 props。

</Box>
);
}
Expand Down
Loading