Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
185 changes: 159 additions & 26 deletions src/renderer/src/components/footer/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
/* 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, 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';
Expand Down Expand Up @@ -35,6 +45,8 @@ interface MessageInputProps {
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart: () => void
onCompositionEnd: () => void
onAttachFiles: (files: FileList | null) => void
attachedCount: number
}

// Reusable components
Expand Down Expand Up @@ -81,55 +93,151 @@ 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="0" 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 })}
</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);

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"
>
<HStack spacing="2" flexWrap="wrap">
{attachedImages.map((image, index) => (
<Box
key={`${image.data}-${index}`}
Copy link
Copy Markdown

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}
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="128px"
objectFit="cover"
/>
<IconButton
aria-label={t('footer.removeAttachment')}
icon={<BsX />}
size="xs" // 先用 xs 当基准
w="18px"
h="18px"
minW="18px" // IconButton 默认有 minW,不设会缩不下去
p="0"
fontSize="12px" // 控制图标大小(icon 会吃到 fontSize)
position="absolute"
top="1"
right="1"
borderRadius="full"
bg="blackAlpha.700"
color="whiteAlpha.900"
_hover={{ bg: 'blackAlpha.800' }}
onClick={(event) => {
event.stopPropagation();
handleRemoveAttachment(index);
}}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

这里的 IconButton 样式是内联定义的,并且包含中文注释。为了更好的代码组织和可维护性,建议将这些样式提取到 src/renderer/src/components/footer/footer-styles.tsx 文件中,并移除代码中的注释。

你可以在 footer-styles.tsxFooterStyles 接口和 footerStyles.footer 对象中添加一个新的样式属性 removeAttachmentButton

// In src/renderer/src/components/footer/footer-styles.tsx
// ...
interface FooterStyles {
  // ...
  removeAttachmentButton: SystemStyleObject
}

export const footerStyles: {
  footer: FooterStyles
  // ...
} = {
  footer: {
    // ...
    removeAttachmentButton: {
      size: "xs",
      w: "18px",
      h: "18px",
      minW: "18px",
      p: "0",
      fontSize: "12px",
      position: "absolute",
      top: "1",
      right: "1",
      borderRadius: "full",
      bg: "blackAlpha.700",
      color: "whiteAlpha.900",
      _hover: { bg: "blackAlpha.800" },
    },
  },
  // ...
}

然后在这里使用它,这样代码会更整洁。

Suggested change
<IconButton
aria-label={t('footer.removeAttachment')}
icon={<BsX />}
size="xs" // 先用 xs 当基准
w="18px"
h="18px"
minW="18px" // IconButton 默认有 minW,不设会缩不下去
p="0"
fontSize="12px" // 控制图标大小(icon 会吃到 fontSize)
position="absolute"
top="1"
right="1"
borderRadius="full"
bg="blackAlpha.700"
color="whiteAlpha.900"
_hover={{ bg: 'blackAlpha.800' }}
onClick={(event) => {
event.stopPropagation();
handleRemoveAttachment(index);
}}
/>
<IconButton
aria-label={t('footer.removeAttachment')}
icon={<BsX />}
{...footerStyles.footer.removeAttachmentButton}
onClick={(event) => {
event.stopPropagation();
handleRemoveAttachment(index);
}}
/>

</Box>
))}
</HStack>
</Box>
)}
<HStack width="100%" gap={4} align="flex-start">
<Box flexShrink={0}>
<Box mb="1.5">
<AIStateIndicator />
</Box>
Expand All @@ -146,9 +254,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
Copy link
Copy Markdown

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