refactor: remove heroui

commit 7c8bf8b591
Author: defi-failure <159208748+defi-failure@users.noreply.github.com>
Date:   Thu Nov 6 17:59:38 2025 +0800

    fix: add token usage to agent session message

commit ff8e5ddd27
Author: defi-failure <159208748+defi-failure@users.noreply.github.com>
Date:   Thu Nov 6 17:25:54 2025 +0800

    fix: close prompt stream when finish or error chunk received

commit 530e6516fd
Author: defi-failure <159208748+defi-failure@users.noreply.github.com>
Date:   Thu Nov 6 17:19:53 2025 +0800

    chore: code cleanup

commit ab21c0d56c
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 16:13:36 2025 +0800

    feat(SessionItem): implement auto-rename feature for sessions and improve context menu handling

    - Added a new context menu option to automatically rename sessions based on topics.
    - Introduced useDeferredValue for managing target session state.
    - Updated imports to include necessary thunk actions and components.
    - Enhanced API service to handle optional assistant model in message summary fetching.
    - Exported renameAgentSessionIfNeeded function for better accessibility in the store.

commit 21ea8ccf37
Merge: ab7b207d2 816a92c60
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 15:29:09 2025 +0800

    Merge branch 'main' of github.com:CherryHQ/cherry-studio into refactor/heroui-antd

    # Conflicts:
    #	src/renderer/src/pages/home/Tabs/components/AddButton.tsx
    #	src/renderer/src/pages/home/Tabs/components/SessionItem.tsx
    #	src/renderer/src/pages/home/Tabs/components/Sessions.tsx
    #	src/renderer/src/pages/home/Tabs/components/Topics.tsx
    #	src/renderer/src/pages/paintings/NewApiPage.tsx

commit ab7b207d29
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 14:50:05 2025 +0800

    refactor: streamline event listener management in useAppInit and update ToolPermissionRequestCard styling

commit 3834c5d402
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 14:21:25 2025 +0800

    refactor: enhance API server state management and remove unused initialization in useAppInit

commit a64b94a41f
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 13:21:58 2025 +0800

    refactor: update OpenAPI documentation paths to include subdirectories for better route coverage

commit 2e0ff28505
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 12:26:09 2025 +0800

    refactor: center align columns in InstalledPluginsList and set AntTable size to small

commit 84bf94e2ff
Author: defi-failure <159208748+defi-failure@users.noreply.github.com>
Date:   Thu Nov 6 12:06:09 2025 +0800

    refactor: align create agent model selection with edit agent

commit 84f2281506
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 11:29:32 2025 +0800

    refactor: integrate API server functionality into various components and enhance user notifications

commit 4e01210df4
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 10:56:38 2025 +0800

    refactor: replace ContextMenu with Dropdown in AgentItem and SessionItem components for improved context menu handling

commit 9df38c7e83
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 10:27:30 2025 +0800

    refactor: update AddButton styling to use CSS variable for border radius and remove unused settings hook

commit 251c269ab3
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 10:11:21 2025 +0800

    refactor: remove unused error handling alerts from AssistantsTab component

commit 9b9640d8d1
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 10:07:26 2025 +0800

    refactor: adjust margin styling for UnifiedAddButton component

commit edd6b11aa7
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 10:04:01 2025 +0800

    refactor: update AddButton styling based on topic position and clean up CSS for root element

commit 1c0de625d8
Author: kangfenmao <kangfenmao@qq.com>
Date:   Thu Nov 6 09:56:42 2025 +0800

    fix: update assistant addition messages for multiple languages

commit 0ea4dd4e3a
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 21:01:24 2025 +0800

    fix: init message api err

commit f3bbd4ed44
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 20:42:49 2025 +0800

    refactor: remove heroui

commit d01609fc36
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 19:08:41 2025 +0800

    refactor: migrate heroui/toast to antd message

commit f4b14dfc10
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 18:51:29 2025 +0800

    refactor: enhance Sessions component layout with styled Scrollbar and adjust UnifiedAddButton margins

commit 6ae5f69163
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 18:44:13 2025 +0800

    refactor: update PluginSettings and ToolingSettings for improved layout and functionality

commit fcb0020787
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 18:29:52 2025 +0800

    wip

commit 02265f369e
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 17:26:39 2025 +0800

    fix: error block related

commit 5e22d9d36f
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 17:14:25 2025 +0800

    fix: note head nav related

commit 3f52b7766a
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 16:45:49 2025 +0800

    chore: remove dead code

commit 484622f12b
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 16:43:12 2025 +0800

    chore: remove dead code

commit 2bceb302e0
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 15:33:25 2025 +0800

    fix: tool setting related

commit 5c455f25eb
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 13:59:33 2025 +0800

    chore: remove dead code

commit d1d1dbc046
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 13:51:41 2025 +0800

    fix: tool permission card related

commit bf4ec23ef7
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 12:22:53 2025 +0800

    fix: remove button and modal renaming

commit 47db5baeb1
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 12:20:36 2025 +0800

    fix: plugin setting related

commit 81fecce552
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 12:16:42 2025 +0800

    refactor: enhance ChatNavbarContent structure by replacing Breadcrumbs with custom layout and adding separators

commit fc64b6c611
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 12:10:48 2025 +0800

    refactor: simplify MessageAgentTools component structure by removing unnecessary wrapper div

commit e0f383a050
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 12:08:32 2025 +0800

    fix: update button classes in AddAssistantOrAgentPopup for improved cursor behavior

commit 720284262f
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 12:06:58 2025 +0800

    refactor: update AgentModal to use TopView for improved modal management and enhance form structure

commit b334a2c5be
Author: kangfenmao <kangfenmao@qq.com>
Date:   Wed Nov 5 11:40:47 2025 +0800

    refactor: replace UpdateDialog with UpdateDialogPopup for better modal handling

commit 468aebd632
Author: dev <verc20.dev@proton.me>
Date:   Wed Nov 5 10:56:40 2025 +0800

    fix: plugins related wip

commit bd4a979f62
Author: dev <verc20.dev@proton.me>
Date:   Tue Nov 4 17:46:14 2025 +0800

    fix: add button related

commit b3316a4dc8
Author: dev <verc20.dev@proton.me>
Date:   Tue Nov 4 17:18:31 2025 +0800

    fix: agent tool result related components

commit 6ca7597a98
Author: dev <verc20.dev@proton.me>
Date:   Tue Nov 4 11:12:01 2025 +0800

    fix: lint

commit 7d0f0b38a6
Author: kangfenmao <kangfenmao@qq.com>
Date:   Tue Nov 4 09:56:32 2025 +0800

    wip

commit 96a607a410
Author: kangfenmao <kangfenmao@qq.com>
Date:   Mon Nov 3 20:23:25 2025 +0800

    wip

commit 235ad16252
Author: kangfenmao <kangfenmao@qq.com>
Date:   Mon Nov 3 20:08:45 2025 +0800

    wip

commit f23fe1b9e9
Author: kangfenmao <kangfenmao@qq.com>
Date:   Mon Nov 3 19:15:01 2025 +0800

    wip

commit 28fac543fc
Author: kangfenmao <kangfenmao@qq.com>
Date:   Mon Nov 3 18:39:39 2025 +0800

    wip

commit 3cc7ee01e2
Author: kangfenmao <kangfenmao@qq.com>
Date:   Mon Nov 3 17:33:13 2025 +0800

    wip

commit 37bdf9e508
Author: kangfenmao <kangfenmao@qq.com>
Date:   Sat Nov 1 19:16:58 2025 +0800

    wip

commit 1bf5104f97
Author: kangfenmao <kangfenmao@qq.com>
Date:   Sat Nov 1 12:12:01 2025 +0800

    wip
This commit is contained in:
kangfenmao 2025-11-06 18:04:26 +08:00
parent 76483d828e
commit 78278ce96d
110 changed files with 3053 additions and 5792 deletions

View File

@ -7,7 +7,6 @@ This file provides guidance to AI coding assistants when working with code in th
- **Keep it clear**: Write code that is easy to read, maintain, and explain.
- **Match the house style**: Reuse existing patterns, naming, and conventions.
- **Search smart**: Prefer `ast-grep` for semantic queries; fall back to `rg`/`grep` when needed.
- **Build with HeroUI**: Use HeroUI for every new UI component; never add `antd` or `styled-components`.
- **Log centrally**: Route all logging through `loggerService` with the right context—no `console.log`.
- **Research via subagent**: Lean on `subagent` for external docs, APIs, news, and references.
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
@ -41,7 +40,6 @@ This file provides guidance to AI coding assistants when working with code in th
- **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
### Logging
```typescript

View File

@ -147,7 +147,6 @@
"@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch",
"@hello-pangea/dnd": "^18.0.1",
"@heroui/react": "^2.8.3",
"@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^1.0.0",
"@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch",
@ -349,6 +348,7 @@
"striptags": "^3.2.0",
"styled-components": "^6.1.11",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tar": "^7.4.3",
"tiny-pinyin": "^1.3.2",

View File

@ -171,7 +171,7 @@ const swaggerOptions: swaggerJSDoc.Options = {
}
]
},
apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts']
apis: ['./src/main/apiServer/routes/**/*.ts', './src/main/apiServer/app.ts']
}
export function setupOpenAPIDocumentation(app: Express) {

View File

@ -365,6 +365,16 @@ class ClaudeCodeService implements AgentServiceInterface {
type: 'chunk',
chunk
})
// Close prompt stream when SDK signals completion or error
if (chunk.type === 'finish' || chunk.type === 'error') {
logger.info('Closing prompt stream as SDK signaled completion', {
chunkType: chunk.type,
reason: chunk.type === 'finish' ? 'finished' : 'error_occurred'
})
closePromptStream()
logger.info('Prompt stream closed successfully')
}
}
}

View File

@ -6,11 +6,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { ToastPortal } from './components/ToastPortal'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { HeroUIProvider } from './context/HeroUIProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
@ -34,24 +32,21 @@ function App(): React.ReactElement {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<HeroUIProvider>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
<ToastPortal />
</HeroUIProvider>
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<Router />
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>
</QueryClientProvider>
</Provider>
)

View File

@ -41,11 +41,11 @@ body,
margin: 0;
}
/* #root {
#root {
display: flex;
flex-direction: row;
flex: 1;
} */
}
body {
display: flex;

View File

@ -1,10 +1,6 @@
@import 'tailwindcss' source('../../../../renderer');
@import 'tw-animate-css';
/* heroui */
@plugin '../../hero.ts';
@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));
/* 如需自定义:
@ -156,11 +152,6 @@
body {
@apply bg-background text-foreground;
}
/* To disable drag title bar on toast. tailwind css doesn't provide such class name. */
.hero-toast {
-webkit-app-region: no-drag;
}
}
:root {

View File

@ -1,31 +0,0 @@
import { Avatar, cn } from '@heroui/react'
import { getModelLogoById } from '@renderer/config/models'
import type { ApiModel } from '@renderer/types'
import React from 'react'
import Ellipsis from './Ellipsis'
export interface ModelLabelProps extends Omit<React.ComponentPropsWithRef<'div'>, 'children'> {
model?: ApiModel
classNames?: {
container?: string
avatar?: string
modelName?: string
divider?: string
providerName?: string
}
}
export const ApiModelLabel: React.FC<ModelLabelProps> = ({ model, className, classNames, ...props }) => {
return (
<div className={cn('flex items-center gap-1', className, classNames?.container)} {...props}>
<Avatar
src={model ? (getModelLogoById(model.id) ?? getModelLogoById(model.name)) : undefined}
className={cn('h-4 w-4', classNames?.avatar)}
/>
<Ellipsis className={classNames?.modelName}>{model?.name}</Ellipsis>
<span className={classNames?.divider}> | </span>
<Ellipsis className={classNames?.providerName}>{model?.provider_name}</Ellipsis>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import { Button, Popover } from 'antd'
import React from 'react'
import EmojiPicker from '../EmojiPicker'
@ -10,13 +10,10 @@ type Props = {
export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return (
<Popover>
<PopoverTrigger>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly />
</PopoverTrigger>
<PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
</PopoverContent>
<Popover content={<EmojiPicker onEmojiClick={onPick} />} trigger="click">
<Button type="text" style={{ width: 32, height: 32, fontSize: 18 }}>
{emoji}
</Button>
</Popover>
)
}

View File

@ -1,4 +1,4 @@
import { cn } from '@heroui/react'
import { cn } from '@renderer/utils'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import React, { memo } from 'react'

View File

@ -1,5 +1,5 @@
import { Button } from '@heroui/react'
import { CheckIcon, XIcon } from 'lucide-react'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
import { Button } from 'antd'
import type { FC } from 'react'
import { createPortal } from 'react-dom'
@ -28,12 +28,22 @@ const ConfirmDialog: FC<Props> = ({ x, y, message, onConfirm, onCancel }) => {
<div className="flex min-w-[160px] items-center rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] p-3 shadow-[0_4px_12px_rgba(0,0,0,0.15)]">
<div className="mr-2 text-sm leading-[1.4]">{message}</div>
<div className="flex justify-center gap-2">
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger">
<XIcon className="text-danger-foreground" size={16} />
</Button>
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success">
<CheckIcon className="text-success-foreground" size={16} />
</Button>
<Button
onClick={onCancel}
shape="circle"
size="small"
danger
icon={<CloseOutlined />}
style={{ width: 24, height: 24, minWidth: 24 }}
/>
<Button
onClick={onConfirm}
shape="circle"
size="small"
type="primary"
icon={<CheckOutlined />}
style={{ width: 24, height: 24, minWidth: 24, backgroundColor: '#52c41a' }}
/>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
import { Button } from '@heroui/button'
import { formatErrorMessage } from '@renderer/utils/error'
import { Button } from 'antd'
import { Alert, Space } from 'antd'
import type { ComponentType, ReactNode } from 'react'
import type { FallbackProps } from 'react-error-boundary'
@ -24,10 +24,10 @@ const DefaultFallback: ComponentType<FallbackProps> = (props: FallbackProps): Re
type="error"
action={
<Space>
<Button size="sm" onPress={debug}>
<Button size="small" onClick={debug}>
{t('error.boundary.default.devtools')}
</Button>
<Button size="sm" onPress={reload}>
<Button size="small" onClick={reload}>
{t('error.boundary.default.reload')}
</Button>
</Space>

View File

@ -1,5 +1,5 @@
import { cn } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { cn } from '@renderer/utils'
import { ChevronRight } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'

View File

@ -1,5 +1,5 @@
import { cn } from '@heroui/react'
import { TopView } from '@renderer/components/TopView'
import { cn } from '@renderer/utils'
import { Modal } from 'antd'
import { Bot, MessageSquare } from 'lucide-react'
import { useState } from 'react'
@ -51,7 +51,7 @@ const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
<button
type="button"
onClick={() => handleSelect('assistant')}
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
className="group flex cursor-pointer flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
onMouseEnter={() => setHoveredOption('assistant')}
onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">
@ -73,7 +73,7 @@ const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
<button
onClick={() => handleSelect('agent')}
type="button"
className="group flex flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
className="group flex cursor-pointer flex-col items-center gap-3 rounded-lg bg-[var(--color-background-soft)] p-6 transition-all hover:bg-[var(--color-hover)]"
onMouseEnter={() => setHoveredOption('agent')}
onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors">

View File

@ -1,11 +1,8 @@
import { Button } from '@heroui/button'
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@heroui/modal'
import { Progress } from '@heroui/progress'
import { Spinner } from '@heroui/spinner'
import { loggerService } from '@logger'
import { AppLogo } from '@renderer/config/env'
import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
import type { WebSocketCandidatesResponse } from '@shared/config/types'
import { Alert, Button, Modal, Progress, Spin } from 'antd'
import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -25,7 +22,7 @@ const LoadingQRCode: React.FC = () => {
const { t } = useTranslation()
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spinner />
<Spin />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')}
</span>
@ -44,8 +41,8 @@ const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
size={200}
imageSettings={{
src: AppLogo,
width: 60,
height: 60,
width: 40,
height: 40,
excavate: true
}}
/>
@ -72,7 +69,7 @@ const ConnectingAnimation: React.FC = () => {
borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)'
}}>
<Spinner size="lg" color="warning" />
<Spin size="large" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')}
</span>
@ -137,7 +134,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
const { t } = useTranslation()
@ -299,22 +295,20 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
// 尝试关闭弹窗 - 如果正在传输则显示确认
const handleCancel = useCallback(() => {
if (isSending) {
setShowCloseConfirm(true)
window.modal.confirm({
title: t('settings.data.export_to_phone.lan.confirm_close_title'),
content: t('settings.data.export_to_phone.lan.confirm_close_message'),
centered: true,
okButtonProps: {
danger: true
},
okText: t('settings.data.export_to_phone.lan.force_close'),
onOk: () => setIsOpen(false)
})
} else {
setIsOpen(false)
}
}, [isSending])
// 确认强制关闭
const handleForceClose = useCallback(() => {
logger.info('Force closing popup during transfer')
setIsOpen(false)
}, [])
// 取消关闭确认
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false)
}, [])
}, [isSending, t])
// 清理并关闭
const handleClose = useCallback(async () => {
@ -376,11 +370,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: '8px',
padding: '5px 12px',
width: '100%',
backgroundColor: connectionStatusStyles.bg,
border: `1px solid ${connectionStatusStyles.border}`
border: `1px solid ${connectionStatusStyles.border}`,
marginBottom: 10
}}>
<span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div>
@ -412,7 +408,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
if (!isSending && transferPhase !== 'completed') return null
return (
<div style={{ paddingTop: '8px' }}>
<div style={{ paddingTop: '20px' }}>
<div
style={{
display: 'flex',
@ -441,11 +437,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</div>
<Progress
value={Math.round(sendProgress)}
size="md"
color={transferPhase === 'completed' ? 'success' : 'primary'}
showValueLabel={false}
aria-label="Send progress"
percent={Math.round(sendProgress)}
status={transferPhase === 'completed' ? 'success' : 'active'}
showInfo={false}
/>
</div>
</div>
@ -488,95 +482,50 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return (
<Modal
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
handleCancel()
}
}}
isDismissable={false}
isKeyboardDismissDisabled={false}
placement="center"
onClose={handleClose}>
<ModalContent>
{() => (
<>
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
<ModalBody>
<SettingRow>
<StatusIndicator />
</SettingRow>
open={isOpen}
onCancel={handleCancel}
afterClose={handleClose}
title={t('settings.data.export_to_phone.lan.title')}
centered
closable={!isSending}
maskClosable={false}
keyboard={true}
footer={null}
styles={{ body: { paddingBottom: 10 } }}>
<SettingRow>
<StatusIndicator />
</SettingRow>
<SettingRow>
<div>{t('settings.data.export_to_phone.lan.content')}</div>
</SettingRow>
<Alert message={t('settings.data.export_to_phone.lan.content')} type="info" style={{ borderRadius: 0 }} />
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px', marginBlock: 25 }}>
<QRCodeDisplay />
</SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')}
</Button>
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button>
</div>
</SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center', marginBlock: 10 }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button onClick={handleSelectZip} disabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')}
</Button>
<Button type="primary" onClick={handleSendZip} disabled={!canSend} loading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button>
</div>
</SettingRow>
<SettingHelpText
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
</SettingHelpText>
<SettingHelpText
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center'
}}>
{selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')}
</SettingHelpText>
<TransferProgress />
<AutoCloseCountdown />
<ErrorDisplay />
</ModalBody>
{showCloseConfirm && (
<ModalFooter>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '12px',
padding: '8px',
borderRadius: '8px',
backgroundColor: 'var(--color-status-warning)',
border: '1px solid var(--color-status-warning)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}></span>
<span style={{ fontSize: '14px', color: 'var(--color-text)', fontWeight: '500' }}>
{t('settings.data.export_to_phone.lan.confirm_close_title')}
</span>
</div>
<span style={{ fontSize: '13px', color: 'var(--color-text-2)', marginLeft: '28px' }}>
{t('settings.data.export_to_phone.lan.confirm_close_message')}
</span>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<Button size="sm" color="default" variant="flat" onPress={handleCancelClose}>
{t('common.cancel')}
</Button>
<Button size="sm" color="danger" onPress={handleForceClose}>
{t('settings.data.export_to_phone.lan.force_close')}
</Button>
</div>
</div>
</ModalFooter>
)}
</>
)}
</ModalContent>
<TransferProgress />
<AutoCloseCountdown />
<ErrorDisplay />
</Modal>
)
}

View File

@ -0,0 +1,205 @@
import { loggerService } from '@logger'
import { TopView } from '@renderer/components/TopView'
import { handleSaveData } from '@renderer/store'
import { Button, Modal } from 'antd'
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
import styled from 'styled-components'
const logger = loggerService.withContext('UpdateDialog')
interface ShowParams {
releaseInfo: UpdateInfo | null
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ releaseInfo, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
setOpen(false)
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
UpdateDialogPopup.hide = onCancel
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
title={
<ModalHeaderWrapper>
<h3>{t('update.title')}</h3>
<p>{t('update.message').replace('{{version}}', releaseInfo?.version || '')}</p>
</ModalHeaderWrapper>
}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
width={720}
footer={[
<Button key="later" onClick={onCancel} disabled={isInstalling}>
{t('update.later')}
</Button>,
<Button key="install" type="primary" onClick={handleInstall} loading={isInstalling}>
{t('update.install')}
</Button>
]}>
<ModalBodyWrapper>
<ReleaseNotesWrapper className="markdown">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</ReleaseNotesWrapper>
</ModalBodyWrapper>
</Modal>
)
}
const TopViewKey = 'UpdateDialogPopup'
export default class UpdateDialogPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
const ModalHeaderWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
}
p {
margin: 0;
font-size: 14px;
color: var(--color-text-2);
}
`
const ModalBodyWrapper = styled.div`
max-height: 450px;
overflow-y: auto;
padding: 12px 0;
`
const ReleaseNotesWrapper = styled.div`
background-color: var(--color-bg-2);
border-radius: 8px;
p {
margin: 0 0 12px 0;
color: var(--color-text-2);
font-size: 14px;
line-height: 1.6;
&:last-child {
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 16px 0 8px 0;
color: var(--color-text-1);
font-weight: 600;
&:first-child {
margin-top: 0;
}
}
ul,
ol {
margin: 8px 0;
padding-left: 24px;
color: var(--color-text-2);
}
li {
margin: 4px 0;
}
code {
padding: 2px 6px;
background-color: var(--color-bg-3);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
pre {
padding: 12px;
background-color: var(--color-bg-3);
border-radius: 6px;
overflow-x: auto;
code {
padding: 0;
background-color: transparent;
}
}
`

View File

@ -1,44 +1,32 @@
import type { SelectedItemProps } from '@heroui/react'
import {
Button,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectItem,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { TopView } from '@renderer/components/TopView'
import { permissionModeCards } from '@renderer/config/agent'
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
import type {
AddAgentForm,
AgentEntity,
AgentType,
ApiModel,
BaseAgentForm,
PermissionMode,
Tool,
UpdateAgentForm
} from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Avatar, Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { ErrorBoundary } from '../../ErrorBoundary'
import type { BaseOption, ModelOption } from './shared'
import { Option, renderOption } from './shared'
import type { BaseOption } from './shared'
const { TextArea } = Input
const logger = loggerService.withContext('AddAgentPopup')
@ -48,8 +36,6 @@ interface AgentTypeOption extends BaseOption {
name: AgentEntity['name']
}
type Option = AgentTypeOption | ModelOption
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
@ -64,58 +50,37 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
})
type Props = {
interface ShowParams {
agent?: AgentWithTools
isOpen: boolean
onClose: () => void
afterSubmit?: (a: AgentEntity) => void
}
/**
* Modal component for creating or editing an agent.
*
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => {
const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { addAgent } = useAgents()
const { updateAgent } = useUpdateAgent()
// hard-coded. We only support anthropic for now.
const { models } = useApiModels({ providerType: 'anthropic' })
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
useEffect(() => {
if (isOpen) {
if (open) {
setForm(buildAgentForm(agent))
}
}, [agent, isOpen])
}, [agent, open])
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const onPermissionModeChange = useCallback((keys: Selection) => {
if (keys === 'all') {
return
}
const [first] = Array.from(keys)
if (!first) {
return
}
const onPermissionModeChange = useCallback((value: PermissionMode) => {
setForm((prev) => {
const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {})
const nextMode = first as PermissionMode
if (parsedConfiguration.permission_mode === nextMode) {
if (parsedConfiguration.permission_mode === value) {
if (!prev.configuration) {
return {
...prev,
@ -129,7 +94,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
...prev,
configuration: {
...parsedConfiguration,
permission_mode: nextMode
permission_mode: value
}
}
})
@ -150,55 +115,57 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
[]
)
const agentOptions: AgentTypeOption[] = useMemo(
const agentOptions = useMemo(
() =>
agentConfig.map(
(option) =>
({
...option,
rendered: <Option option={option} />
}) as const satisfies SelectedItemProps
),
agentConfig.map((option) => ({
value: option.key,
label: (
<OptionWrapper>
<Avatar src={option.avatar} size={24} />
<span>{option.label}</span>
</OptionWrapper>
)
})),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
(value: AgentType) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === e.target.value)
const newConfig = agentConfig.find((config) => config.key === value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: e.target.value as AgentType,
type: value,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
const onNameChange = useCallback((name: string) => {
const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({
...prev,
name
name: e.target.value
}))
}, [])
const onDescChange = useCallback((description: string) => {
const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
...prev,
description
description: e.target.value
}))
}, [])
const onInstChange = useCallback((instructions: string) => {
const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({
...prev,
instructions
instructions: e.target.value
}))
}, [])
@ -231,34 +198,36 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
}))
}, [])
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? [])
.filter((m) =>
agentModelFilter({
id: m.id,
provider: m.provider || '',
name: m.name,
group: ''
})
)
.map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogoById(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
// Create a temporary agentBase object for SelectAgentBaseModelButton
const tempAgentBase: AgentEntity = useMemo(
() => ({
id: agent?.id ?? 'temp-creating',
type: form.type,
name: form.name,
model: form.model,
accessible_paths: form.accessible_paths.length > 0 ? form.accessible_paths : ['/'],
allowed_tools: form.allowed_tools ?? [],
description: form.description,
instructions: form.instructions,
configuration: form.configuration,
created_at: agent?.created_at ?? new Date().toISOString(),
updated_at: agent?.updated_at ?? new Date().toISOString()
}),
[form, agent?.id, agent?.created_at, agent?.updated_at]
)
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
const handleModelSelect = useCallback(async (model: ApiModel) => {
setForm((prev) => ({ ...prev, model: model.id }))
}, [])
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
@ -330,9 +299,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
afterSubmit?.(result.data)
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
setOpen(false)
},
[
form.type,
@ -344,7 +311,6 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
form.allowed_tools,
form.configuration,
agent,
onClose,
t,
updateAgent,
afterSubmit,
@ -352,138 +318,312 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
]
)
AgentModalPopup.hide = onCancel
return (
<ErrorBoundary>
<Modal
isOpen={isOpen}
onClose={onClose}
classNames={{
base: 'max-h-[90vh]',
wrapper: 'overflow-hidden'
}}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
<Form onSubmit={onSubmit} className="min-h-0 w-full shrink overflow-auto">
<ModalBody className="min-h-0 w-full flex-1 shrink overflow-auto">
<div className="flex gap-2">
<Select
isRequired
isDisabled={isEditing(agent)}
selectionMode="single"
selectedKeys={[form.type]}
disallowEmptySelection
onChange={onAgentTypeChange}
items={agentOptions}
label={t('agent.type.label')}
placeholder={t('agent.add.type.placeholder')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
</div>
<Select
isRequired
selectionMode="single"
selectedKeys={form.model ? [form.model] : []}
disallowEmptySelection
onChange={onModelChange}
items={modelOptions}
label={t('common.model')}
placeholder={t('common.placeholders.select.model')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Select
isRequired
selectionMode="single"
selectedKeys={[selectedPermissionMode]}
onSelectionChange={onPermissionModeChange}
label={t('agent.settings.tooling.permissionMode.title', 'Permission mode')}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
description={t(
'agent.settings.tooling.permissionMode.helper',
'Choose how the agent handles tool approvals.'
)}
items={permissionModeCards}>
{(item) => (
<SelectItem key={item.mode} textValue={t(item.titleKey, item.titleFallback)}>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{t(item.titleKey, item.titleFallback)}</span>
<span className="text-foreground-500 text-xs">
{t(item.descriptionKey, item.descriptionFallback)}
</span>
<span className="text-foreground-400 text-xs">
{t(item.behaviorKey, item.behaviorFallback)}
</span>
{item.caution ? (
<span className="flex items-center gap-1 text-danger-500 text-xs">
<AlertTriangleIcon size={12} className="text-danger" />
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</span>
) : null}
title={isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
width={500}
footer={null}>
<StyledForm onSubmit={onSubmit}>
<FormContent>
<FormRow>
<FormItem style={{ flex: 1 }}>
<Label>{t('agent.type.label')}</Label>
<Select
value={form.type}
onChange={onAgentTypeChange}
options={agentOptions}
disabled={isEditing(agent)}
style={{ width: '100%' }}
/>
</FormItem>
<FormItem style={{ flex: 1 }}>
<Label>
{t('common.name')} <RequiredMark>*</RequiredMark>
</Label>
<Input value={form.name} onChange={onNameChange} required />
</FormItem>
</FormRow>
<FormItem>
<Label>
{t('common.model')} <RequiredMark>*</RequiredMark>
</Label>
<SelectAgentBaseModelButton
agentBase={tempAgentBase}
onSelect={handleModelSelect}
fontSize={14}
avatarSize={24}
iconSize={16}
buttonStyle={{
padding: '8px 12px',
width: '100%',
border: '1px solid var(--color-border)',
borderRadius: 6,
height: 'auto'
}}
containerClassName="flex items-center justify-between w-full"
/>
</FormItem>
<FormItem>
<Label>
{t('agent.settings.tooling.permissionMode.title', 'Permission mode')} <RequiredMark>*</RequiredMark>
</Label>
<Select
value={selectedPermissionMode}
onChange={onPermissionModeChange}
style={{ width: '100%' }}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
dropdownStyle={{ minWidth: '500px' }}
optionLabelProp="label">
{permissionModeCards.map((item) => (
<Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
<PermissionOptionWrapper>
<div className="title">{t(item.titleKey, item.titleFallback)}</div>
<div className="description">{t(item.descriptionKey, item.descriptionFallback)}</div>
<div className="behavior">{t(item.behaviorKey, item.behaviorFallback)}</div>
{item.caution && (
<div className="caution">
<AlertTriangleIcon size={12} />
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</div>
</SelectItem>
)}
</Select>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground text-sm">
{t('agent.session.accessible_paths.label')}
</span>
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
{t('agent.session.accessible_paths.add')}
)}
</PermissionOptionWrapper>
</Select.Option>
))}
</Select>
<HelpText>
{t('agent.settings.tooling.permissionMode.helper', 'Choose how the agent handles tool approvals.')}
</HelpText>
</FormItem>
<FormItem>
<LabelWithButton>
<Label>
{t('agent.session.accessible_paths.label')} <RequiredMark>*</RequiredMark>
</Label>
<Button size="small" onClick={addAccessiblePath}>
{t('agent.session.accessible_paths.add')}
</Button>
</LabelWithButton>
{form.accessible_paths.length > 0 ? (
<PathList>
{form.accessible_paths.map((path) => (
<PathItem key={path}>
<PathText title={path}>{path}</PathText>
<Button size="small" danger onClick={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</div>
{form.accessible_paths.length > 0 ? (
<div className="space-y-2">
{form.accessible_paths.map((path) => (
<div
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</div>
))}
</div>
) : (
<p className="text-foreground-400 text-sm">{t('agent.session.accessible_paths.empty')}</p>
)}
</div>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
<Textarea
label={t('common.description')}
value={form.description ?? ''}
onValueChange={onDescChange}
/>
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>
</>
)}
</ModalContent>
</PathItem>
))}
</PathList>
) : (
<EmptyText>{t('agent.session.accessible_paths.empty')}</EmptyText>
)}
</FormItem>
<FormItem>
<Label>{t('common.prompt')}</Label>
<TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
</FormItem>
<FormItem>
<Label>{t('common.description')}</Label>
<TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
</FormItem>
</FormContent>
<FormFooter>
<Button onClick={onCancel}>{t('common.close')}</Button>
<Button type="primary" htmlType="submit" loading={loadingRef.current}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</FormFooter>
</StyledForm>
</Modal>
</ErrorBoundary>
)
}
const TopViewKey = 'AgentModalPopup'
export default class AgentModalPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}
// Keep the old export for backward compatibility during migration
export const AgentModal = AgentModalPopup
const StyledForm = styled.form`
display: flex;
flex-direction: column;
gap: 16px;
`
const FormContent = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
max-height: 60vh;
overflow-y: auto;
padding-right: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 3px;
}
`
const FormRow = styled.div`
display: flex;
gap: 12px;
`
const FormItem = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const Label = styled.label`
font-size: 14px;
color: var(--color-text-1);
font-weight: 500;
`
const RequiredMark = styled.span`
color: #ff4d4f;
margin-left: 4px;
`
const HelpText = styled.div`
font-size: 12px;
color: var(--color-text-3);
`
const LabelWithButton = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
const PathList = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const PathItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-bg-1);
`
const PathText = styled.span`
flex: 1;
font-size: 13px;
color: var(--color-text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`
const EmptyText = styled.p`
font-size: 13px;
color: var(--color-text-3);
margin: 0;
`
const FormFooter = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
`
const OptionWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const PermissionOptionWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0;
.title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 2px;
}
.description {
font-size: 12px;
color: var(--color-text-2);
line-height: 1.4;
}
.behavior {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.4;
}
.caution {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: #ff4d4f;
margin-top: 4px;
padding: 6px 8px;
background-color: rgba(255, 77, 79, 0.1);
border-radius: 4px;
svg {
flex-shrink: 0;
margin-top: 2px;
}
}
`

View File

@ -1,320 +0,0 @@
import {
Button,
cn,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import { AllowedToolsSelect } from '@renderer/components/agent'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import type {
AgentEntity,
AgentSessionEntity,
BaseSessionForm,
CreateSessionForm,
Tool,
UpdateSessionForm
} from '@renderer/types'
import type { FormEvent, ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary'
const logger = loggerService.withContext('SessionAgentPopup')
type AgentWithTools = AgentEntity & { tools?: Tool[] }
type SessionWithTools = AgentSessionEntity & { tools?: Tool[] }
const buildSessionForm = (existing?: SessionWithTools, agent?: AgentWithTools): BaseSessionForm => ({
name: existing?.name ?? agent?.name ?? 'Claude Code',
description: existing?.description ?? agent?.description,
instructions: existing?.instructions ?? agent?.instructions,
model: existing?.model ?? agent?.model ?? '',
accessible_paths: existing?.accessible_paths
? [...existing.accessible_paths]
: agent?.accessible_paths
? [...agent.accessible_paths]
: [],
allowed_tools: existing?.allowed_tools
? [...existing.allowed_tools]
: agent?.allowed_tools
? [...agent.allowed_tools]
: [],
mcps: existing?.mcps ? [...existing.mcps] : agent?.mcps ? [...agent.mcps] : []
})
interface BaseProps {
agentId: string
session?: SessionWithTools
onSessionCreated?: (session: AgentSessionEntity) => void
}
interface TriggerProps extends BaseProps {
trigger: { content: ReactNode; className?: string }
isOpen?: never
onClose?: never
}
interface StateProps extends BaseProps {
trigger?: never
isOpen: boolean
onClose: () => void
}
type Props = TriggerProps | StateProps
/**
* Modal component for creating or editing a Session.
* @deprecated may as a reference when migrating to v2
*
* Either trigger or isOpen and onClose is given.
* @param agentId - The ID of agent which the session is related.
* @param session - Optional session entity for editing mode.
* @param trigger - Optional trigger element that opens the modal. It MUST propagate the click event to trigger the modal.
* @param isOpen - Optional controlled modal open state. From useDisclosure.
* @param onClose - Optional callback when modal closes. From useDisclosure.
* @returns Modal component for agent creation/editing
*/
export const SessionModal: React.FC<Props> = ({
agentId,
session,
trigger,
isOpen: _isOpen,
onClose: _onClose,
onSessionCreated
}) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession } = useSessions(agentId)
const { updateSession } = useUpdateSession(agentId)
const { agent } = useAgent(agentId)
const isEditing = (session?: AgentSessionEntity) => session !== undefined
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session, agent ?? undefined))
useEffect(() => {
if (isOpen) {
setForm(buildSessionForm(session, agent ?? undefined))
}
}, [session, agent, isOpen])
const availableTools = useMemo(() => session?.tools ?? agent?.tools ?? [], [agent?.tools, session?.tools])
const selectedToolKeys = useMemo(() => new Set(form.allowed_tools ?? []), [form.allowed_tools])
useEffect(() => {
if (!availableTools.length) {
return
}
setForm((prev) => {
const allowed = prev.allowed_tools ?? []
const validTools = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
if (validTools.length === allowed.length) {
return prev
}
return {
...prev,
allowed_tools: validTools
}
})
}, [availableTools])
const onNameChange = useCallback((name: string) => {
setForm((prev) => ({
...prev,
name
}))
}, [])
const onDescChange = useCallback((description: string) => {
setForm((prev) => ({
...prev,
description
}))
}, [])
const onInstChange = useCallback((instructions: string) => {
setForm((prev) => ({
...prev,
instructions
}))
}, [])
const onAllowedToolsChange = useCallback(
(keys: Selection) => {
setForm((prev) => {
const existing = prev.allowed_tools ?? []
if (keys === 'all') {
return {
...prev,
allowed_tools: availableTools.map((tool) => tool.id)
}
}
const next = Array.from(keys).map(String)
const filtered = availableTools.length
? next.filter((id) => availableTools.some((tool) => tool.id === id))
: next
if (existing.length === filtered.length && existing.every((id) => filtered.includes(id))) {
return prev
}
return {
...prev,
allowed_tools: filtered
}
})
},
[availableTools]
)
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (loadingRef.current) {
return
}
loadingRef.current = true
// Additional validation check besides native HTML validation to ensure security
if (!form.model) {
window.toast.error(t('error.model.not_exists'))
loadingRef.current = false
return
}
if (form.accessible_paths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.error.at_least_one'))
loadingRef.current = false
return
}
try {
if (isEditing(session)) {
if (!session) {
throw new Error('Agent is required for editing mode')
}
const updatePayload = {
id: session.id,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths],
allowed_tools: [...(form.allowed_tools ?? [])],
mcps: [...(form.mcps ?? [])]
} satisfies UpdateSessionForm
updateSession(updatePayload)
logger.debug('Updated agent', updatePayload)
} else {
const newSession = {
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths],
allowed_tools: [...(form.allowed_tools ?? [])],
mcps: [...(form.mcps ?? [])]
} satisfies CreateSessionForm
const createdSession = await createSession(newSession)
if (createdSession) {
onSessionCreated?.(createdSession)
}
logger.debug('Added agent', newSession)
}
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
} finally {
loadingRef.current = false
}
},
[
form.model,
form.name,
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
form.mcps,
session,
onClose,
onSessionCreated,
t,
updateSession,
createSession
]
)
return (
<ErrorBoundary>
{/* NOTE: Hero UI Modal Pattern: Combine the Button and Modal components into a single
encapsulated component. This is because the Modal component needs to bind the onOpen
event handler to the Button for proper focus management.
Or just use external isOpen/onOpen/onClose to control modal state.
*/}
{trigger && (
<div
onClick={(e) => {
e.stopPropagation()
onOpen()
}}
className={cn('w-full', trigger.className)}>
{trigger.content}
</div>
)}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>
{isEditing(session) ? t('agent.session.edit.title') : t('agent.session.add.title')}
</ModalHeader>
<Form onSubmit={onSubmit} className="w-full">
<ModalBody className="w-full">
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
<Textarea
label={t('common.description')}
value={form.description ?? ''}
onValueChange={onDescChange}
/>
<AllowedToolsSelect
items={availableTools}
selectedKeys={selectedToolKeys}
onSelectionChange={onAllowedToolsChange}
/>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{isEditing(session) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>
</>
)}
</ModalContent>
</Modal>
</ErrorBoundary>
)
}

View File

@ -1,8 +1,3 @@
import type { SelectedItemProps, SelectedItems } from '@heroui/react'
import { Avatar } from '@heroui/react'
import { getProviderLabel } from '@renderer/i18n/label'
import { useTranslation } from 'react-i18next'
export interface BaseOption {
type: 'type' | 'model'
key: string
@ -10,43 +5,3 @@ export interface BaseOption {
// img src
avatar?: string
}
export interface ModelOption extends BaseOption {
providerId?: string
providerName?: string
}
export function isModelOption(option: BaseOption): option is ModelOption {
return option.type === 'model'
}
export const Item = ({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />
export const renderOption = (items: SelectedItems<BaseOption>) =>
items.map((item) => <Item key={item.key} item={item} />)
export const Option = ({ option }: { option?: BaseOption | null }) => {
const { t } = useTranslation()
if (!option) {
return (
<div className="flex gap-2">
<Avatar name="?" className="h-5 w-5" />
{t('common.invalid_value')}
</div>
)
}
const providerLabel = (() => {
if (!isModelOption(option)) return null
if (option.providerName) return option.providerName
if (option.providerId) return getProviderLabel(option.providerId)
return null
})()
return (
<div className="flex gap-2">
<Avatar src={option.avatar} className="h-5 w-5" />
<span className="truncate">{option.label}</span>
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
</div>
)
}

View File

@ -1,32 +0,0 @@
import { ToastProvider } from '@heroui/toast'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
export const ToastPortal = () => {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
if (!mounted) return null
return createPortal(
<ToastProvider
placement="top-center"
regionProps={{
className: 'z-[1001]'
}}
toastOffset={20}
toastProps={{
timeout: 3000,
classNames: {
// This setting causes the 'hero-toast' class to be applied twice to the toast element. This is weird and I don't know why, but it works.
base: 'hero-toast'
}
}}
/>,
document.body
)
}

View File

@ -2,12 +2,12 @@
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { Modal } from 'antd'
import { message, Modal } from 'antd'
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box } from '../Layout'
import { getToastUtilities } from './toast'
import { getToastUtilities, initMessageApi } from './toast'
let onPop = () => {}
let onShow = ({ element, id }: { element: React.FC | React.ReactNode; id: string }) => {
@ -36,6 +36,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
elementsRef.current = elements
const [modal, modalContextHolder] = Modal.useModal()
const [messageApi, messageContextHolder] = message.useMessage()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
@ -43,8 +44,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
useEffect(() => {
window.modal = modal
initMessageApi(messageApi)
window.toast = getToastUtilities()
}, [modal])
}, [messageApi, modal])
onPop = () => {
const views = [...elementsRef.current]
@ -97,6 +99,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
return (
<>
{children}
{messageContextHolder}
{modalContextHolder}
<TopViewMinappContainer />
{elements.map(({ element: Element, id }) => (

View File

@ -1,72 +0,0 @@
import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import type { RequireSome } from '@renderer/types'
type AddToastProps = Parameters<typeof addToast>[0]
type ToastPropsColored = Omit<AddToastProps, 'color'>
const createToast = (color: 'danger' | 'success' | 'warning' | 'default') => {
return (arg: ToastPropsColored | string): string | null => {
if (typeof arg === 'string') {
return addToast({ color, title: arg })
} else {
return addToast({ color, ...arg })
}
}
}
// syntatic sugar, oh yeah
/**
* Display an error toast notification with red color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const error = createToast('danger')
/**
* Display a success toast notification with green color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const success = createToast('success')
/**
* Display a warning toast notification with yellow color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const warning = createToast('warning')
/**
* Display an info toast notification with default color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const info = createToast('default')
/**
* Display a loading toast notification that resolves with a promise
* @param args - Toast options object containing a promise to resolve
* @returns Toast ID or null
*/
export const loading = (args: RequireSome<AddToastProps, 'promise'>) => {
// Disappear immediately by default
if (args.timeout === undefined) {
args.timeout = 1
}
return addToast(args)
}
export const getToastUtilities = () =>
({
getToastQueue,
addToast,
closeToast,
closeAll,
isToastClosing,
error,
success,
warning,
info,
loading
}) as const

View File

@ -0,0 +1,231 @@
import type { RequireSome } from '@renderer/types'
import { message as antdMessage } from 'antd'
import type { MessageInstance } from 'antd/es/message/interface'
import type React from 'react'
// Global message instance for static usage
let messageApi: MessageInstance | null = null
// Initialize message API - should be called once the App component is mounted
export const initMessageApi = (api: MessageInstance) => {
messageApi = api
}
// Get message API instance
const getMessageApi = (): MessageInstance => {
if (!messageApi) {
// Fallback to static method if hook API is not available
return antdMessage
}
return messageApi
}
type ToastColor = 'danger' | 'success' | 'warning' | 'default'
type MessageType = 'error' | 'success' | 'warning' | 'info'
interface ToastConfig {
title?: React.ReactNode
icon?: React.ReactNode
description?: React.ReactNode
timeout?: number
key?: string | number
className?: string
style?: React.CSSProperties
onClick?: () => void
onClose?: () => void
}
interface LoadingToastConfig extends ToastConfig {
promise: Promise<any>
}
const colorToType = (color: ToastColor): MessageType => {
switch (color) {
case 'danger':
return 'error'
case 'success':
return 'success'
case 'warning':
return 'warning'
case 'default':
return 'info'
}
}
// Toast content component
const ToastContent: React.FC<{ title?: React.ReactNode; description?: React.ReactNode; icon?: React.ReactNode }> = ({
title,
description,
icon
}) => {
return (
<div className="flex flex-col gap-1">
{(icon || title) && (
<div className="flex items-center gap-2 font-semibold">
{icon}
{title}
</div>
)}
{description && <div className="text-sm">{description}</div>}
</div>
)
}
const createToast = (color: ToastColor) => {
return (arg: ToastConfig | string): string | null => {
const api = getMessageApi()
const type = colorToType(color) as 'error' | 'success' | 'warning' | 'info'
if (typeof arg === 'string') {
// antd message methods return a function to close the message
api[type](arg)
return null
}
const { title, description, icon, timeout, ...restConfig } = arg
// Convert timeout from milliseconds to seconds (antd uses seconds)
const duration = timeout !== undefined ? timeout / 1000 : 3
return (
(api.open({
type: type as 'error' | 'success' | 'warning' | 'info',
content: <ToastContent title={title} description={description} icon={icon} />,
duration,
...restConfig
}) as any) || null
)
}
}
/**
* Display an error toast notification with red color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const error = createToast('danger')
/**
* Display a success toast notification with green color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const success = createToast('success')
/**
* Display a warning toast notification with yellow color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const warning = createToast('warning')
/**
* Display an info toast notification with default color
* @param arg - Toast content (string) or toast options object
* @returns Toast ID or null
*/
export const info = createToast('default')
/**
* Display a loading toast notification that resolves with a promise
* @param args - Toast options object containing a promise to resolve
*/
export const loading = (args: RequireSome<LoadingToastConfig, 'promise'>): string | null => {
const api = getMessageApi()
const { title, description, icon, promise, timeout, ...restConfig } = args
// Generate unique key for this loading message
const key = args.key || `loading-${Date.now()}-${Math.random()}`
// Show loading message
api.loading({
content: <ToastContent title={title || 'Loading...'} description={description} icon={icon} />,
duration: 0, // Don't auto-close
key,
...restConfig
})
// Handle promise resolution
promise
.then((result) => {
api.success({
content: <ToastContent title={title || 'Success'} description={description} />,
duration: timeout !== undefined ? timeout / 1000 : 2,
key,
...restConfig
})
return result
})
.catch((err) => {
api.error({
content: (
<ToastContent title={title || 'Error'} description={err?.message || description || 'An error occurred'} />
),
duration: timeout !== undefined ? timeout / 1000 : 3,
key,
...restConfig
})
throw err
})
return key as string
}
/**
* Add a toast notification
* @param config - Toast configuration object
* @returns Toast ID or null
*/
export const addToast = (config: ToastConfig) => info(config)
/**
* Close a specific toast notification by its key
* @param key - Toast key (string)
*/
export const closeToast = (key: string) => {
getMessageApi().destroy(key)
}
/**
* Close all toast notifications
*/
export const closeAll = () => {
getMessageApi().destroy()
}
/**
* Stub functions for compatibility with previous toast API
* These are no-ops since antd message doesn't expose a queue
*/
/**
* @deprecated This function is a no-op stub for backward compatibility only.
* Antd message doesn't expose a queue. Do not rely on this function.
* @returns Empty toast queue stub
*/
export const getToastQueue = (): any => ({ toasts: [] })
/**
* @deprecated This function is a no-op stub for backward compatibility only.
* Antd message doesn't track closing state. Do not rely on this function.
* @param key - Toast key (unused)
* @returns Always returns false
*/
export const isToastClosing = (key?: string): boolean => {
key // unused
return false
}
export const getToastUtilities = () =>
({
getToastQueue,
addToast,
closeToast,
closeAll,
isToastClosing,
error,
success,
warning,
info,
loading
}) as const

View File

@ -1,101 +0,0 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { handleSaveData } from '@renderer/store'
import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
const logger = loggerService.withContext('UpdateDialog')
interface UpdateDialogProps {
isOpen: boolean
onClose: () => void
releaseInfo: UpdateInfo | null
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({ isOpen, onClose, releaseInfo }) => {
const { t } = useTranslation()
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
if (isOpen && releaseInfo) {
logger.info('Update dialog opened', { version: releaseInfo.version })
}
}, [isOpen, releaseInfo])
const handleInstall = async () => {
setIsInstalling(true)
try {
await handleSaveData()
await window.api.quitAndInstall()
} catch (error) {
logger.error('Failed to save data before update', error as Error)
setIsInstalling(false)
window.toast.error(t('update.saveDataError'))
}
}
const releaseNotes = releaseInfo?.releaseNotes
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
base: 'max-h-[85vh]',
header: 'border-b border-divider',
footer: 'border-t border-divider'
}}>
<ModalContent>
{(onModalClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h3 className="font-semibold text-lg">{t('update.title')}</h3>
<p className="text-default-500 text-small">
{t('update.message').replace('{{version}}', releaseInfo?.version || '')}
</p>
</ModalHeader>
<ModalBody>
<ScrollShadow className="max-h-[450px]" hideScrollBar>
<div className="markdown rounded-lg bg-default-50 p-4">
<Markdown>
{typeof releaseNotes === 'string'
? releaseNotes
: Array.isArray(releaseNotes)
? releaseNotes
.map((note: ReleaseNoteInfo) => note.note)
.filter(Boolean)
.join('\n\n')
: t('update.noReleaseNotes')}
</Markdown>
</div>
</ScrollShadow>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onModalClose} isDisabled={isInstalling}>
{t('update.later')}
</Button>
<Button
color="primary"
onPress={async () => {
await handleInstall()
onModalClose()
}}
isLoading={isInstalling}>
{t('update.install')}
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)
}
export default UpdateDialog

View File

@ -1,55 +0,0 @@
import type { SelectedItems, SelectProps } from '@heroui/react'
import { Chip, cn, Select, SelectItem } from '@heroui/react'
import type { Tool } from '@renderer/types'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export interface AllowedToolsSelectProps extends Omit<SelectProps, 'children'> {
items: Tool[]
}
export const AllowedToolsSelect: React.FC<AllowedToolsSelectProps> = (props) => {
const { t } = useTranslation()
const { items: availableTools, className, ...rest } = props
const renderSelectedTools = useCallback((items: SelectedItems<Tool>) => {
if (!items.length) {
return null
}
return (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Chip key={item.key} size="sm" variant="flat" className="max-w-[160px] truncate">
{item.data?.name ?? item.textValue ?? item.key}
</Chip>
))}
</div>
)
}, [])
return (
<Select
aria-label={t('agent.session.allowed_tools.label')}
selectionMode="multiple"
isMultiline
label={t('agent.session.allowed_tools.label')}
placeholder={t('agent.session.allowed_tools.placeholder')}
description={
availableTools.length ? t('agent.session.allowed_tools.helper') : t('agent.session.allowed_tools.empty')
}
isDisabled={!availableTools.length}
items={availableTools}
renderValue={renderSelectedTools}
className={cn('max-w-xl', className)}
{...rest}>
{(tool) => (
<SelectItem key={tool.id} textValue={tool.name}>
<div className="flex flex-col">
<span className="font-medium text-sm">{tool.name}</span>
{tool.description ? <span className="text-foreground-500 text-xs">{tool.description}</span> : null}
</div>
</SelectItem>
)}
</Select>
)
}

View File

@ -1 +0,0 @@
export { AllowedToolsSelect } from './AllowedToolsSelect'

View File

@ -1,13 +0,0 @@
import { HeroUIProvider } from '@heroui/react'
import { useSettings } from '@renderer/hooks/useSettings'
const AppHeroUIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { language } = useSettings()
return (
<HeroUIProvider className="flex h-full w-full flex-1" locale={language}>
{children}
</HeroUIProvider>
)
}
export { AppHeroUIProvider as HeroUIProvider }

View File

@ -1,12 +1,22 @@
/// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import type { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast'
import type KeyvStorage from '@kangfenmao/keyv-storage'
import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom'
import type { error, info, loading, success, warning } from './components/TopView/toast'
import type {
addToast,
closeAll,
closeToast,
error,
getToastQueue,
info,
isToastClosing,
loading,
success,
warning
} from './components/TopView/toast'
interface ImportMetaEnv {
VITE_RENDERER_INTEGRATED_MODEL: string

View File

@ -1,2 +0,0 @@
import { heroui } from '@heroui/react'
export default heroui()

View File

@ -41,6 +41,7 @@ export const useAgents = () => {
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat

View File

@ -31,21 +31,24 @@ export const useApiServer = () => {
try {
const status = await window.api.apiServer.getStatus()
setApiServerRunning(status.running)
if (status.running && !apiServerConfig.enabled) {
setApiServerEnabled(true)
}
} catch (error: any) {
logger.error('Failed to check API server status:', error)
} finally {
setApiServerLoading(false)
}
}, [])
}, [apiServerConfig.enabled, setApiServerEnabled])
const startApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.start()
if (result.success) {
setApiServerRunning(true)
setApiServerEnabled(true)
window.toast.success(t('apiServer.messages.startSuccess'))
} else {
window.toast.error(t('apiServer.messages.startError') + result.error)
@ -55,16 +58,16 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
}, [apiServerLoading, setApiServerEnabled, t])
const stopApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.stop()
if (result.success) {
setApiServerRunning(false)
setApiServerEnabled(false)
window.toast.success(t('apiServer.messages.stopSuccess'))
} else {
window.toast.error(t('apiServer.messages.stopError') + result.error)
@ -74,14 +77,14 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, t])
}, [apiServerLoading, setApiServerEnabled, t])
const restartApiServer = useCallback(async () => {
if (apiServerLoading) return
setApiServerLoading(true)
try {
const result = await window.api.apiServer.restart()
setApiServerEnabled(result.success)
if (result.success) {
await checkApiServerStatus()
window.toast.success(t('apiServer.messages.restartSuccess'))
@ -93,7 +96,7 @@ export const useApiServer = () => {
} finally {
setApiServerLoading(false)
}
}, [apiServerLoading, checkApiServerStatus, t])
}, [apiServerLoading, checkApiServerStatus, setApiServerEnabled, t])
useEffect(() => {
checkApiServerStatus()

View File

@ -221,13 +221,12 @@ export function useAppInit() {
}
}
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
const removeListeners = [
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Request, requestListener),
window.electron.ipcRenderer.on(IpcChannel.AgentToolPermission_Result, resultListener)
]
return () => {
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Request, requestListener)
window.electron?.ipcRenderer.removeListener(IpcChannel.AgentToolPermission_Result, resultListener)
}
return () => removeListeners.forEach((removeListener) => removeListener())
}, [dispatch, t])
useEffect(() => {

View File

@ -12,19 +12,7 @@ export default function useUserTheme() {
const colorPrimary = Color(theme.colorPrimary)
document.body.style.setProperty('--color-primary', colorPrimary.toString())
// overwrite hero UI primary color.
document.body.style.setProperty('--primary', colorPrimary.toString())
document.body.style.setProperty('--heroui-primary', colorPrimary.toString())
document.body.style.setProperty('--heroui-primary-900', colorPrimary.lighten(0.5).toString())
document.body.style.setProperty('--heroui-primary-800', colorPrimary.lighten(0.4).toString())
document.body.style.setProperty('--heroui-primary-700', colorPrimary.lighten(0.3).toString())
document.body.style.setProperty('--heroui-primary-600', colorPrimary.lighten(0.2).toString())
document.body.style.setProperty('--heroui-primary-500', colorPrimary.lighten(0.1).toString())
document.body.style.setProperty('--heroui-primary-400', colorPrimary.toString())
document.body.style.setProperty('--heroui-primary-300', colorPrimary.darken(0.1).toString())
document.body.style.setProperty('--heroui-primary-200', colorPrimary.darken(0.2).toString())
document.body.style.setProperty('--heroui-primary-100', colorPrimary.darken(0.3).toString())
document.body.style.setProperty('--heroui-primary-50', colorPrimary.darken(0.4).toString())
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())

View File

@ -1646,7 +1646,7 @@
},
"assistant": {
"added": {
"content": "智能体添加成功"
"content": "助手添加成功"
}
},
"attachments": {

View File

@ -1646,7 +1646,7 @@
},
"assistant": {
"added": {
"content": "智慧代理人新增成功"
"content": "助手新增成功"
}
},
"attachments": {

View File

@ -1611,7 +1611,7 @@
},
"assistant": {
"added": {
"content": "Agent erfolgreich hinzugefügt"
"content": "Assistent erfolgreich hinzugefügt"
}
},
"attachments": {

View File

@ -1611,7 +1611,7 @@
},
"assistant": {
"added": {
"content": "Ο ενεργοποιημένος αστρόναυτης προστέθηκε επιτυχώς"
"content": "Ο βοηθός προστέθηκε επιτυχώς"
}
},
"attachments": {

View File

@ -1611,7 +1611,7 @@
},
"assistant": {
"added": {
"content": "アシスタントが追加されました"
"content": "助手が追加されました"
}
},
"attachments": {

View File

@ -1,4 +1,3 @@
import { Alert } from '@heroui/react'
import { loggerService } from '@logger'
import type { ContentSearchRef } from '@renderer/components/ContentSearch'
import { ContentSearch } from '@renderer/components/ContentSearch'
@ -17,7 +16,7 @@ import { useTimer } from '@renderer/hooks/useTimer'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { Assistant, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { Flex } from 'antd'
import { Alert, Flex } from 'antd'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/react'
import type { FC } from 'react'
@ -170,11 +169,7 @@ const Chat: FC<Props> = (props) => {
return () => <div> Active Session ID is invalid.</div>
}
if (!apiServer.enabled) {
return () => (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
return () => <Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: '5px 16px' }} />
}
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={activeSessionId} />
}, [activeAgentId, activeSessionId, apiServer.enabled, t])
@ -191,22 +186,14 @@ const Chat: FC<Props> = (props) => {
// TODO: more info
const AgentInvalid = useCallback(() => {
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<Alert color="warning" title="Select an agent" />
</div>
</div>
)
return <Alert type="warning" message="Select an agent" style={{ margin: '5px 16px' }} />
}, [])
// TODO: more info
const SessionInvalid = useCallback(() => {
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<Alert color="warning" title="Create a session" />
</div>
<Alert type="warning" message="Create a session" style={{ margin: '5px 16px' }} />
</div>
)
}, [])

View File

@ -1,4 +1,3 @@
import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
@ -11,6 +10,7 @@ import { useShortcutDisplay } from '@renderer/hooks/useShortcuts'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
@ -22,6 +22,7 @@ import { abortCompletion } from '@renderer/utils/abortController'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
import { Tooltip } from 'antd'
import type { TextAreaRef } from 'antd/es/input/TextArea'
import TextArea from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
@ -199,11 +200,15 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}
: undefined
// Calculate token usage for the user message
const usage = await estimateUserPromptUsage({ content: text })
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
id: userMessageId,
blocks: userMessageBlocks.map((block) => block?.id),
model,
modelId: model?.id
modelId: model?.id,
usage
})
const assistantStub: Assistant = {
@ -309,7 +314,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
/>
<Toolbar>
<ToolbarGroup>
<Tooltip placement="top" content={t('chat.input.new_topic', { Command: newTopicShortcut })} delay={0}>
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: newTopicShortcut })}>
<ActionIconButton
onClick={handleCreateSession}
disabled={createSessionDisabled}
@ -321,7 +326,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
<ToolbarGroup>
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
<Tooltip placement="top" title={t('chat.input.pause')}>
<ActionIconButton onClick={abortAgentSession} style={{ marginRight: -2 }}>
<CirclePause size={20} color="var(--color-error)" />
</ActionIconButton>

View File

@ -1,4 +1,3 @@
import { Button } from '@heroui/button'
import CodeViewer from '@renderer/components/CodeViewer'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useTimer } from '@renderer/hooks/useTimer'
@ -33,6 +32,7 @@ import {
} from '@renderer/types/error'
import type { ErrorMessageBlock, Message } from '@renderer/types/newMessage'
import { formatAiSdkError, formatError, safeToString } from '@renderer/utils/error'
import { Button } from 'antd'
import { Alert as AntdAlert, Modal } from 'antd'
import React, { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -144,9 +144,11 @@ const MessageErrorInfo: React.FC<{ block: ErrorMessageBlock; message: Message }>
onClick={showErrorDetail}
style={{ cursor: 'pointer' }}
action={
<Button size="sm" className="p-0" variant="light" onPress={showErrorDetail}>
{t('common.detail')}
</Button>
<>
<Button size="middle" color="default" variant="text" onClick={showErrorDetail}>
{t('common.detail')}
</Button>
</>
}
/>
<ErrorDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} error={block.error} />
@ -198,10 +200,10 @@ const ErrorDetailModal: React.FC<ErrorDetailModalProps> = ({ open, onClose, erro
open={open}
onCancel={onClose}
footer={[
<Button key="copy" size="sm" variant="light" onPress={copyErrorDetails}>
<Button key="copy" variant="text" color="default" onClick={copyErrorDetails}>
{t('common.copy')}
</Button>,
<Button key="close" size="sm" variant="light" onPress={onClose}>
<Button key="close" variant="text" color={'default'} onClick={onClose}>
{t('common.close')}
</Button>
]}

View File

@ -1,6 +1,6 @@
import { Spinner } from '@heroui/react'
import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage'
import React from 'react'
import { BeatLoader } from 'react-spinners'
import styled from 'styled-components'
interface PlaceholderBlockProps {
@ -10,7 +10,7 @@ const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ block }) => {
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
return (
<MessageContentLoading>
<Spinner color="current" variant="dots" />
<BeatLoader color="var(--color-text-1)" size={8} speedMultiplier={0.8} />
</MessageContentLoading>
)
}

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar'
@ -15,7 +14,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import type { Assistant, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { classNames, cn } from '@renderer/utils'
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
import { Divider } from 'antd'
import type { Dispatch, FC, SetStateAction } from 'react'

View File

@ -1,4 +1,5 @@
import { AccordionItem, Chip, Code } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
@ -15,7 +16,13 @@ interface ParsedBashOutput {
tool_use_error?: string
}
export function BashOutputTool({ input, output }: { input: BashOutputToolInput; output?: BashOutputToolOutput }) {
export function BashOutputTool({
input,
output
}: {
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 解析 XML 输出
const parsedOutput = useMemo(() => {
if (!output) return null
@ -84,93 +91,88 @@ export function BashOutputTool({ input, output }: { input: BashOutputToolInput;
} as const
}, [parsedOutput])
return (
<AccordionItem
key={AgentToolsType.BashOutput}
aria-label="BashOutput Tool"
title={
const children = parsedOutput ? (
<div className="flex flex-col gap-4">
{/* Status Info */}
<div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && (
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag>
)}
{parsedOutput.timestamp && (
<Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag>
)}
</div>
{/* Standard Output */}
{parsedOutput.stdout && (
<div>
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{parsedOutput.stdout}
</pre>
</div>
)}
{/* Standard Error */}
{parsedOutput.stderr && (
<div className="border border-danger-200">
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.stderr}
</pre>
</div>
)}
{/* Tool Use Error */}
{parsedOutput.tool_use_error && (
<div className="border border-danger-200">
<div className="mb-2 flex items-center gap-2">
<XCircle className="h-4 w-4 text-danger" />
<span className="font-medium text-danger-600 text-xs">Error:</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.tool_use_error}
</pre>
</div>
)}
</div>
) : (
// 原始输出(如果解析失败或非 XML 格式)
output && (
<div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
</div>
)
)
return {
key: AgentToolsType.BashOutput,
label: (
<>
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash Output"
params={
<div className="flex items-center gap-2">
<Code size="sm" className="py-0 text-xs">
{input.bash_id}
</Code>
<Tag className="py-0 font-mono text-xs">{input.bash_id}</Tag>
{statusConfig && (
<Chip
size="sm"
<Tag
color={statusConfig.color}
variant="flat"
startContent={statusConfig.icon}
className="h-5">
icon={statusConfig.icon}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{statusConfig.text}
</Chip>
</Tag>
)}
</div>
}
/>
}
classNames={{
content: 'space-y-3 px-1'
}}>
{parsedOutput ? (
<>
{/* Status Info */}
<div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && (
<Chip size="sm" color={parsedOutput.exit_code === 0 ? 'success' : 'danger'} variant="flat">
Exit Code: {parsedOutput.exit_code}
</Chip>
)}
{parsedOutput.timestamp && (
<Code size="sm" className="py-0 text-xs">
{new Date(parsedOutput.timestamp).toLocaleString()}
</Code>
)}
</div>
</>
),
{/* Standard Output */}
{parsedOutput.stdout && (
<div>
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{parsedOutput.stdout}
</pre>
</div>
)}
{/* Standard Error */}
{parsedOutput.stderr && (
<div className="border border-danger-200">
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.stderr}
</pre>
</div>
)}
{/* Tool Use Error */}
{parsedOutput.tool_use_error && (
<div className="border border-danger-200">
<div className="mb-2 flex items-center gap-2">
<XCircle className="h-4 w-4 text-danger" />
<span className="font-medium text-danger-600 text-xs">Error:</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.tool_use_error}
</pre>
</div>
)}
</>
) : (
// 原始输出(如果解析失败或非 XML 格式)
output && (
<div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
</div>
)
)}
</AccordionItem>
)
children: children
}
}

View File

@ -1,31 +1,35 @@
import { AccordionItem, Code } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { Terminal } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
export function BashTool({ input, output }: { input: BashToolInputType; output?: BashToolOutputType }) {
export function BashTool({
input,
output
}: {
input: BashToolInputType
output?: BashToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算输出行数
const outputLines = output ? output.split('\n').length : 0
return (
<AccordionItem
key="tool"
aria-label="Bash Tool"
title={
return {
key: 'tool',
label: (
<>
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash"
params={input.description}
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
/>
}
subtitle={
<Code size="sm" className="line-clamp-1 w-max max-w-full text-ellipsis py-0 text-xs">
{input.command}
</Code>
}>
<div className="whitespace-pre-line">{output}</div>
</AccordionItem>
)
<div className="mt-1">
<Tag className="whitespace-pre-wrap break-all font-mono">{input.command}</Tag>
</div>
</>
),
children: <div className="whitespace-pre-line">{output}</div>
}
}

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FileEdit } from 'lucide-react'
import { ToolTitle } from './GenericTools'
@ -28,19 +28,26 @@ export const renderCodeBlock = (content: string, variant: 'old' | 'new') => {
)
}
export function EditTool({ input, output }: { input: EditToolInput; output?: EditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.Edit}
aria-label="Edit Tool"
title={<ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input.file_path} />}>
{/* Diff View */}
{/* Old Content */}
{renderCodeBlock(input.old_string, 'old')}
{/* New Content */}
{renderCodeBlock(input.new_string, 'new')}
{/* Output */}
{output}
</AccordionItem>
)
export function EditTool({
input,
output
}: {
input: EditToolInput
output?: EditToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: AgentToolsType.Edit,
label: <ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input.file_path} />,
children: (
<>
{/* Diff View */}
{/* Old Content */}
{renderCodeBlock(input.old_string, 'old')}
{/* New Content */}
{renderCodeBlock(input.new_string, 'new')}
{/* Output */}
{output}
</>
)
}
}

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { DoorOpen } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
@ -6,19 +6,22 @@ import { ToolTitle } from './GenericTools'
import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types'
import { AgentToolsType } from './types'
export function ExitPlanModeTool({ input, output }: { input: ExitPlanModeToolInput; output?: ExitPlanModeToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.ExitPlanMode}
aria-label="ExitPlanMode Tool"
title={
<ToolTitle
icon={<DoorOpen className="h-4 w-4" />}
label="ExitPlanMode"
stats={`${input.plan.split('\n\n').length} plans`}
/>
}>
{<ReactMarkdown>{input.plan + '\n\n' + (output ?? '')}</ReactMarkdown>}
</AccordionItem>
)
export function ExitPlanModeTool({
input,
output
}: {
input: ExitPlanModeToolInput
output?: ExitPlanModeToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: AgentToolsType.ExitPlanMode,
label: (
<ToolTitle
icon={<DoorOpen className="h-4 w-4" />}
label="ExitPlanMode"
stats={`${input.plan.split('\n\n').length} plans`}
/>
),
children: <ReactMarkdown>{input.plan + '\n\n' + (output ?? '')}</ReactMarkdown>
}
}

View File

@ -1,26 +1,29 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FolderSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types'
export function GlobTool({ input, output }: { input: GlobToolInputType; output?: GlobToolOutputType }) {
export function GlobTool({
input,
output
}: {
input: GlobToolInputType
output?: GlobToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算文件数量
const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Glob Tool"
title={
<ToolTitle
icon={<FolderSearch className="h-4 w-4" />}
label="Glob"
params={input.pattern}
stats={output ? `${lineCount} of output` : undefined}
/>
}>
<div>{output}</div>
</AccordionItem>
)
return {
key: 'tool',
label: (
<ToolTitle
icon={<FolderSearch className="h-4 w-4" />}
label="Glob"
params={input.pattern}
stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined}
/>
),
children: <div>{output}</div>
}
}

View File

@ -1,31 +1,34 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FileSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { GrepToolInput, GrepToolOutput } from './types'
export function GrepTool({ input, output }: { input: GrepToolInput; output?: GrepToolOutput }) {
export function GrepTool({
input,
output
}: {
input: GrepToolInput
output?: GrepToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算结果行数
const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Grep Tool"
title={
<ToolTitle
icon={<FileSearch className="h-4 w-4" />}
label="Grep"
params={
<>
{input.pattern}
{input.output_mode && <span className="ml-1">({input.output_mode})</span>}
</>
}
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
/>
}>
<div>{output}</div>
</AccordionItem>
)
return {
key: 'tool',
label: (
<ToolTitle
icon={<FileSearch className="h-4 w-4" />}
label="Grep"
params={
<>
{input.pattern}
{input.output_mode && <span className="ml-1">({input.output_mode})</span>}
</>
}
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
/>
),
children: <div>{output}</div>
}
}

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { renderCodeBlock } from './EditTool'
@ -6,18 +6,24 @@ import { ToolTitle } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function MultiEditTool({ input }: { input: MultiEditToolInput; output?: MultiEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.MultiEdit}
aria-label="MultiEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input.file_path} />}>
{input.edits.map((edit, index) => (
<div key={index}>
{renderCodeBlock(edit.old_string, 'old')}
{renderCodeBlock(edit.new_string, 'new')}
</div>
))}
</AccordionItem>
)
export function MultiEditTool({
input
}: {
input: MultiEditToolInput
output?: MultiEditToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: AgentToolsType.MultiEdit,
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input.file_path} />,
children: (
<div>
{input.edits.map((edit, index) => (
<div key={index}>
{renderCodeBlock(edit.old_string, 'old')}
{renderCodeBlock(edit.new_string, 'new')}
</div>
))}
</div>
)
}
}

View File

@ -1,4 +1,5 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
@ -6,14 +7,23 @@ import { ToolTitle } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function NotebookEditTool({ input, output }: { input: NotebookEditToolInput; output?: NotebookEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.NotebookEdit}
aria-label="NotebookEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />}
subtitle={input.notebook_path}>
<ReactMarkdown>{output}</ReactMarkdown>
</AccordionItem>
)
export function NotebookEditTool({
input,
output
}: {
input: NotebookEditToolInput
output?: NotebookEditToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: AgentToolsType.NotebookEdit,
label: (
<>
<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />
<Tag className="mt-1" color="blue">
{input.notebook_path}{' '}
</Tag>
</>
),
children: <ReactMarkdown>{output}</ReactMarkdown>
}
}

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
@ -7,7 +7,13 @@ import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
export function ReadTool({ input, output }: { input: ReadToolInputType; output?: ReadToolOutputType }) {
export function ReadTool({
input,
output
}: {
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
@ -53,19 +59,16 @@ export function ReadTool({ input, output }: { input: ReadToolInputType; output?:
}
}, [outputString])
return (
<AccordionItem
key={AgentToolsType.Read}
aria-label="Read Tool"
title={
<ToolTitle
icon={<FileText className="h-4 w-4" />}
label="Read File"
params={input.file_path.split('/').pop()}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
/>
}>
{outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null}
</AccordionItem>
)
return {
key: AgentToolsType.Read,
label: (
<ToolTitle
icon={<FileText className="h-4 w-4" />}
label="Read File"
params={input.file_path.split('/').pop()}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
/>
),
children: outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null
}
}

View File

@ -1,25 +1,30 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Search } from 'lucide-react'
import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools'
import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types'
export function SearchTool({ input, output }: { input: SearchToolInputType; output?: SearchToolOutputType }) {
export function SearchTool({
input,
output
}: {
input: SearchToolInputType
output?: SearchToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Search Tool"
title={
<ToolTitle
icon={<Search className="h-4 w-4" />}
label="Search"
params={`"${input}"`}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
}>
return {
key: 'tool',
label: (
<ToolTitle
icon={<Search className="h-4 w-4" />}
label="Search"
params={`"${input}"`}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
),
children: (
<div>
<StringInputTool input={input} label="Search Query" />
{output && (
@ -28,6 +33,6 @@ export function SearchTool({ input, output }: { input: SearchToolInputType; outp
</div>
)}
</div>
</AccordionItem>
)
)
}
}

View File

@ -1,16 +1,19 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { PencilRuler } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { SkillToolInput, SkillToolOutput } from './types'
export function SkillTool({ input, output }: { input: SkillToolInput; output?: SkillToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Skill Tool"
title={<ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input.command} />}>
{output}
</AccordionItem>
)
export function SkillTool({
input,
output
}: {
input: SkillToolInput
output?: SkillToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: 'tool',
label: <ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input.command} />,
children: <div>{output}</div>
}
}

View File

@ -1,21 +1,28 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Bot } from 'lucide-react'
import Markdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types'
export function TaskTool({ input, output }: { input: TaskToolInputType; output?: TaskToolOutputType }) {
return (
<AccordionItem
key="tool"
aria-label="Task Tool"
title={<ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input.description} />}>
{output?.map((item) => (
<div key={item.type}>
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
</div>
))}
</AccordionItem>
)
export function TaskTool({
input,
output
}: {
input: TaskToolInputType
output?: TaskToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
return {
key: 'tool',
label: <ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input.description} />,
children: (
<div>
{output?.map((item) => (
<div key={item.type}>
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
</div>
))}
</div>
)
}
}

View File

@ -1,4 +1,6 @@
import { AccordionItem, Card, CardBody, Chip } from '@heroui/react'
import { cn } from '@renderer/utils'
import type { CollapseProps } from 'antd'
import { Card } from 'antd'
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
import { ToolTitle } from './GenericTools'
@ -30,44 +32,59 @@ const getStatusConfig = (status: TodoItem['status']) => {
}
}
export function TodoWriteTool({ input }: { input: TodoWriteToolInputType }) {
export function TodoWriteTool({
input
}: {
input: TodoWriteToolInputType
}): NonNullable<CollapseProps['items']>[number] {
const doneCount = input.todos.filter((todo) => todo.status === 'completed').length
return (
<AccordionItem
key={AgentToolsType.TodoWrite}
aria-label="Todo Write Tool"
title={
<ToolTitle
icon={<ListTodo className="h-4 w-4" />}
label="Todo Write"
params={`${doneCount} Done`}
stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`}
/>
}>
return {
key: AgentToolsType.TodoWrite,
label: (
<ToolTitle
icon={<ListTodo className="h-4 w-4" />}
label="Todo Write"
params={`${doneCount} Done`}
stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`}
/>
),
children: (
<div className="space-y-3">
{input.todos.map((todo, index) => {
const statusConfig = getStatusConfig(todo.status)
return (
<Card key={index} className="shadow-sm">
<CardBody className="p-2">
<div className="flex items-start gap-3">
<Chip color={statusConfig.color} variant="flat" size="sm" className="flex-shrink-0">
{statusConfig.icon}
</Chip>
<div className="min-w-0 flex-1">
<div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}>
{todo.status === 'completed' ? <s>{todo.content}</s> : todo.content}
<div key={index}>
<Card
key={index}
className="shadow-sm"
styles={{
body: { padding: 2 }
}}>
<div className="p-2">
<div className="flex items-center justify-center gap-3">
<div
className={cn(
'flex items-center justify-center rounded-full border bg-opacity-50 p-2',
`bg-${statusConfig.color}`
)}>
{statusConfig.icon}
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}>
{todo.status === 'completed' ? <s>{todo.content}</s> : todo.content}
</div>
{todo.status === 'in_progress' && (
<div className="mt-1 text-default-400 text-xs">{todo.activeForm}</div>
)}
</div>
{todo.status === 'in_progress' && (
<div className="mt-1 text-default-400 text-xs">{todo.activeForm}</div>
)}
</div>
</div>
</CardBody>
</Card>
</Card>
</div>
)
})}
</div>
</AccordionItem>
)
)
}
}

View File

@ -1,5 +1,5 @@
import { AccordionItem } from '@heroui/react'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import type { CollapseProps } from 'antd'
import { Wrench } from 'lucide-react'
import { useEffect, useState } from 'react'
@ -11,7 +11,11 @@ interface UnknownToolProps {
output?: unknown
}
export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToolProps) {
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@ -47,17 +51,16 @@ export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToo
return 'Tool'
}
return (
<AccordionItem
key="unknown-tool"
aria-label={toolName}
title={
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription()}
/>
}>
return {
key: 'unknown-tool',
label: (
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription()}
/>
),
children: (
<div className="space-y-3">
{input !== undefined && (
<div>
@ -83,6 +86,6 @@ export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToo
<div className="text-foreground-500 text-xs">No data available for this tool</div>
)}
</div>
</AccordionItem>
)
)
}
}

View File

@ -1,17 +1,19 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WebFetchToolInput, WebFetchToolOutput } from './types'
export function WebFetchTool({ input, output }: { input: WebFetchToolInput; output?: WebFetchToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Web Fetch Tool"
title={<ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input.url} />}
subtitle={input.prompt}>
{output}
</AccordionItem>
)
export function WebFetchTool({
input,
output
}: {
input: WebFetchToolInput
output?: WebFetchToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: 'tool',
label: <ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input.url} />,
children: <div>{output}</div>
}
}

View File

@ -1,26 +1,29 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WebSearchToolInput, WebSearchToolOutput } from './types'
export function WebSearchTool({ input, output }: { input: WebSearchToolInput; output?: WebSearchToolOutput }) {
export function WebSearchTool({
input,
output
}: {
input: WebSearchToolInput
output?: WebSearchToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Web Search Tool"
title={
<ToolTitle
icon={<Globe className="h-4 w-4" />}
label="Web Search"
params={input.query}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
}>
{output}
</AccordionItem>
)
return {
key: 'tool',
label: (
<ToolTitle
icon={<Globe className="h-4 w-4" />}
label="Web Search"
params={input.query}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
),
children: <div>{output}</div>
}
}

View File

@ -1,16 +1,18 @@
import { AccordionItem } from '@heroui/react'
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WriteToolInput, WriteToolOutput } from './types'
export function WriteTool({ input }: { input: WriteToolInput; output?: WriteToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Write Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input.file_path} />}>
<div>{input.content}</div>
</AccordionItem>
)
export function WriteTool({
input
}: {
input: WriteToolInput
output?: WriteToolOutput
}): NonNullable<CollapseProps['items']>[number] {
return {
key: 'tool',
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input.file_path} />,
children: <div>{input.content}</div>
}
}

View File

@ -1,10 +1,13 @@
import { Accordion } from '@heroui/react'
import { loggerService } from '@logger'
import type { NormalToolResponse } from '@renderer/types'
import type { CollapseProps } from 'antd'
import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@ -58,25 +61,27 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
const Renderer = toolRenderers[toolName]
// eslint-disable-next-line react-hooks/rules-of-hooks
const toolContentItem = useMemo(() => {
const rendered = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
return {
...rendered,
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
} as NonNullable<CollapseProps['items']>[number]['classNames']
} as NonNullable<CollapseProps['items']>[number]
}, [Renderer, input, output, toolName])
return (
<div className="w-max max-w-full rounded-md bg-foreground-100 py-1 transition-all duration-300 ease-in-out dark:bg-foreground-100">
<Accordion
className="w-max max-w-full"
itemClasses={{
trigger:
'p-0 [&>div:first-child]:!flex-none [&>div:first-child]:flex [&>div:first-child]:flex-col [&>div:first-child]:text-start [&>div:first-child]:max-w-full',
indicator: 'flex-shrink-0',
subtitle: 'text-xs',
content:
'rounded-md bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll',
base: 'space-y-1'
}}
defaultExpandedKeys={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}>
{Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })}
</Accordion>
</div>
<Collapse
className="w-max max-w-full"
expandIconPosition="end"
size="small"
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
items={[toolContentItem]}
/>
)
}

View File

@ -1,9 +1,9 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import { Button } from 'antd'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -127,33 +127,39 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Chip color={isExpired ? 'danger' : 'warning'} size="sm" variant="flat">
<div
className={`rounded px-2 py-0.5 font-medium text-xs ${
isExpired ? 'text-[var(--color-error)]' : 'text-[var(--color-status-warning)]'
}`}>
{isExpired
? t('agent.toolPermission.expired')
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
</Chip>
</div>
<div className="flex items-center gap-1">
<Button
aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8"
color="danger"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny}
onPress={() => handleDecision('deny')}
startContent={<CircleX size={16} />}
variant="bordered">
disabled={isSubmitting || isExpired}
loading={isSubmittingDeny}
onClick={() => handleDecision('deny')}
icon={<CircleX size={16} />}
iconPosition={'start'}
variant="outlined">
{t('agent.toolPermission.button.cancel')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="success"
isDisabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow}
onPress={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}>
color="primary"
disabled={isSubmitting || isExpired}
loading={isSubmittingAllow}
onClick={() => handleDecision('allow')}
icon={<CirclePlay size={16} />}
iconPosition={'start'}
variant="solid">
{t('agent.toolPermission.button.run')}
</Button>
@ -161,12 +167,12 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
}
className="h-8"
isIconOnly
onPress={() => setShowDetails((value) => !value)}
variant="light">
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />
</Button>
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
onClick={() => setShowDetails((value) => !value)}
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
variant="text"
style={{ backgroundColor: 'transparent' }}
/>
</div>
</div>
</div>
@ -181,9 +187,9 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')}
</p>
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar>
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre>
</ScrollShadow>
<div className="max-h-[192px] overflow-auto font-mono text-xs">
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
</div>
</div>
{request.requiresPermissions && (

View File

@ -1,4 +1,3 @@
import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiServer } from '@renderer/hooks/useApiServer'
@ -7,13 +6,9 @@ import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import type { Assistant, AssistantsSortType, Topic } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import type { FC } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import UnifiedAddButton from './components/UnifiedAddButton'
@ -31,16 +26,12 @@ interface AssistantsTabProps {
onCreateDefaultAssistant: () => void
}
const ALERT_KEY = 'enable_api_server_to_use_agent'
const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const { activeAssistant, setActiveAssistant, onCreateAssistant, onCreateDefaultAssistant } = props
const containerRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const { apiServerConfig, apiServerRunning, apiServerLoading } = useApiServer()
const { apiServerConfig } = useApiServer()
const apiServerEnabled = apiServerConfig.enabled
const { iknow, chat } = useRuntime()
const dispatch = useAppDispatch()
const { chat } = useRuntime()
// Agent related hooks
const { agents, deleteAgent, isLoading: agentsLoading, error: agentsError } = useAgents()
@ -126,31 +117,6 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
return (
<Container className="assistants-tab" ref={containerRef}>
{!apiServerConfig.enabled && !apiServerRunning && !iknow[ALERT_KEY] && (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
className="mb-2"
/>
)}
{(agentsLoading || apiServerLoading) && <Spinner />}
{apiServerConfig.enabled && !apiServerLoading && !apiServerRunning && (
<Alert color="danger" title={t('agent.server.error.not_running')} isClosable className="mb-2" />
)}
{apiServerRunning && agentsError && (
<Alert
color="danger"
title={t('agent.list.error.failed')}
description={getErrorMessage(agentsError)}
className="mb-2"
/>
)}
<UnifiedAddButton
onCreateAssistant={onCreateAssistant}
setActiveAssistant={setActiveAssistant}

View File

@ -1,9 +1,10 @@
import { Button, Divider } from '@heroui/react'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { SettingDivider } from '@renderer/pages/settings'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import AdvancedSettings from '@renderer/pages/settings/AgentSettings/AdvancedSettings'
import EssentialSettings from '@renderer/pages/settings/AgentSettings/EssentialSettings'
import type { GetAgentSessionResponse } from '@renderer/types'
import { Button } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
@ -31,9 +32,10 @@ const SessionSettingsTab: FC<Props> = ({ session, update }) => {
return (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<EssentialSettings agentBase={session} update={update} showModelSetting={false} />
<SettingDivider />
<AdvancedSettings agentBase={session} update={update} />
<Divider className="my-2" />
<Button size="sm" fullWidth onPress={onMoreSetting}>
<SettingDivider />
<Button size="small" block onClick={onMoreSetting}>
{t('settings.moresetting.label')}
</Button>
</div>

View File

@ -1,6 +1,7 @@
import { Alert, cn } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { cn } from '@renderer/utils'
import { Alert } from 'antd'
import { AnimatePresence, motion } from 'framer-motion'
import type { FC } from 'react'
import { memo } from 'react'
@ -17,19 +18,11 @@ const SessionsTab: FC<SessionsTabProps> = () => {
const { apiServer } = useSettings()
if (!apiServer.enabled) {
return (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
return <Alert type="warning" message={t('agent.warning.enable_server')} style={{ margin: 10 }} />
}
if (!activeAgentId) {
return (
<div>
<Alert color="warning" title={'Select an agent'} />
</div>
)
return <Alert type="warning" message={'Select an agent'} style={{ margin: 10 }} />
}
return (

View File

@ -1,18 +1,32 @@
import type { ButtonProps } from '@heroui/react'
import { Button, cn } from '@heroui/react'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import { PlusIcon } from 'lucide-react'
import type { FC } from 'react'
import styled from 'styled-components'
const AddButton = ({ children, className, ...props }: ButtonProps) => {
const StyledButton = styled(Button)`
height: 36px;
width: calc(var(--assistants-width) - 20px);
justify-content: flex-start;
border-radius: var(--list-item-border-radius);
padding: 0 12px;
font-size: 13px;
color: var(--color-text-2);
&:hover {
background-color: var(--color-list-item);
}
`
const AddButton: FC<ButtonProps> = ({ ...props }) => {
return (
<Button
className={cn(
'h-9 w-[calc(var(--assistants-width)-20px)] justify-start rounded-lg bg-transparent px-3 text-[13px] text-[var(--color-text-2)] hover:bg-[var(--color-list-item)]',
className
)}
startContent={<PlusIcon size={16} className="shrink-0" />}
{...props}>
{children}
</Button>
<StyledButton
{...props}
type="text"
onClick={props.onClick}
icon={<PlusIcon size={16} style={{ flexShrink: 0 }} />}>
{props.children}
</StyledButton>
)
}

View File

@ -1,4 +1,3 @@
import { cn, Tooltip } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useSettings } from '@renderer/hooks/useSettings'
@ -6,10 +5,12 @@ import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSett
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { cn } from '@renderer/utils'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { Bot } from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
@ -36,45 +37,52 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
onPress()
}, [clickAssistantToShowTopic, topicPosition, onPress])
const menuItems: MenuProps['items'] = useMemo(
() => [
{
label: t('common.edit'),
key: 'edit',
icon: <EditIcon size={14} />,
onClick: () => AgentSettingsPopup.show({ agentId: agent.id })
},
{
label: t('common.delete'),
key: 'delete',
icon: <DeleteIcon size={14} className="lucide-custom" />,
danger: true,
onClick: () => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}
}
],
[t, agent, onDelete]
)
return (
<ContextMenu modal={false}>
<ContextMenuTrigger>
<Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper>
<AgentLabel agent={agent} />
</AgentNameWrapper>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
</MenuButton>
)}
{!isActive && <BotIcon />}
</AssistantNameRow>
</Container>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem key="edit" onClick={() => AgentSettingsPopup.show({ agentId: agent.id })}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
window.modal.confirm({
title: t('agent.delete.title'),
content: t('agent.delete.content'),
centered: true,
okButtonProps: { danger: true },
onOk: () => onDelete(agent)
})
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper>
<AgentLabel agent={agent} />
</AgentNameWrapper>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
</MenuButton>
)}
{!isActive && <BotIcon />}
</AssistantNameRow>
</Container>
</Dropdown>
)
}
@ -118,7 +126,7 @@ export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ cla
export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...props }) => {
const { t } = useTranslation()
return (
<Tooltip content={t('common.agent_one')} delay={500} closeDelay={0}>
<Tooltip title={t('common.agent_one')} mouseEnterDelay={0.5}>
<MenuButton {...props}>
<Bot size={14} className="text-primary" />
</MenuButton>

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import EmojiIcon from '@renderer/components/EmojiIcon'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
@ -12,7 +11,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, AssistantsSortType } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils'
import { cn, getLeadingEmoji, uuid } from '@renderer/utils'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
import type { MenuProps } from 'antd'
import { Dropdown } from 'antd'

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { isMac } from '@renderer/config/constant'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
@ -8,26 +7,19 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import type { AgentSessionEntity } from '@renderer/types'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@renderer/ui/context-menu'
import { loadTopicMessagesThunk, renameAgentSessionIfNeeded } from '@renderer/store/thunk/messageThunk'
import type { AgentSessionEntity, Assistant } from '@renderer/types'
import { classNames } from '@renderer/utils'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Tooltip } from 'antd'
import { MenuIcon, XIcon } from 'lucide-react'
import type { MenuProps } from 'antd'
import { Dropdown, Tooltip } from 'antd'
import { MenuIcon, Sparkles, XIcon } from 'lucide-react'
import type { FC } from 'react'
import React, { memo, startTransition, useEffect, useMemo, useState } from 'react'
import React, { memo, startTransition, useDeferredValue, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ListItem, ListItemEditInput, ListItemName, ListItemNameContainer, MenuButton, StatusIndicator } from './shared'
import styled from 'styled-components'
// const logger = loggerService.withContext('AgentItem')
@ -46,6 +38,8 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const activeSessionId = chat.activeSessionIdMap[agentId]
const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false)
const { setTimeoutTimer } = useTimer()
const [_targetSession, setTargetSession] = useState<AgentSessionEntity>(session)
const targetSession = useDeferredValue(_targetSession)
const dispatch = useAppDispatch()
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
@ -68,6 +62,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
</div>
}>
<MenuButton
className="menu"
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
if (isConfirmingDeletion || e.ctrlKey || e.metaKey) {
@ -111,82 +106,216 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const { topicPosition, setTopicPosition } = useSettings()
const singlealone = topicPosition === 'right'
const menuItems: MenuProps['items'] = useMemo(
() => [
{
label: t('common.edit'),
key: 'edit',
icon: <EditIcon size={14} />,
onClick: () => {
SessionSettingsPopup.show({
agentId,
sessionId: session.id
})
}
},
{
label: t('chat.topics.auto_rename'),
key: 'auto-rename',
icon: <Sparkles size={14} />,
onClick: () => {
const assistant = {} as Assistant
const agentSession = { agentId: agentId, sessionId: targetSession.id }
dispatch(loadTopicMessagesThunk(sessionTopicId))
renameAgentSessionIfNeeded(agentSession, assistant, sessionTopicId, store.getState)
}
},
{
label: t('settings.topic.position.label'),
key: 'topic-position',
icon: <MenuIcon size={14} />,
children: [
{
label: t('settings.topic.position.left'),
key: 'left',
onClick: () => setTopicPosition('left')
},
{
label: t('settings.topic.position.right'),
key: 'right',
onClick: () => setTopicPosition('right')
}
]
},
{
label: t('common.delete'),
key: 'delete',
icon: <DeleteIcon size={14} className="lucide-custom" />,
danger: true,
onClick: () => {
onDelete()
}
}
],
[agentId, dispatch, onDelete, session.id, sessionTopicId, setTopicPosition, t, targetSession.id]
)
return (
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ListItem
className={cn(
isActive ? 'active' : undefined,
singlealone ? 'singlealone' : undefined,
isEditing ? 'cursor-default' : 'cursor-pointer',
'rounded-[var(--list-item-border-radius)]'
)}
onClick={isEditing ? undefined : onPress}
onDoubleClick={() => startEdit(session.name ?? '')}
title={session.name ?? session.id}>
{isPending && !isActive && <StatusIndicator variant="pending" />}
{isFulfilled && !isActive && <StatusIndicator variant="fulfilled" />}
<ListItemNameContainer>
{isEditing ? (
<ListItemEditInput
ref={inputRef}
value={editValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{ opacity: isSaving ? 0.5 : 1 }}
/>
) : (
<>
<ListItemName>
<SessionLabel session={session} />
</ListItemName>
<DeleteButton />
</>
)}
</ListItemNameContainer>
</ListItem>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
key="edit"
onClick={() => {
SessionSettingsPopup.show({
agentId,
sessionId: session.id
})
}}>
<EditIcon size={14} />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger className="gap-2">
<MenuIcon size={14} />
{t('settings.topic.position.label')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem key="left" onClick={() => setTopicPosition('left')}>
{t('settings.topic.position.left')}
</ContextMenuItem>
<ContextMenuItem key="right" onClick={() => setTopicPosition('right')}>
{t('settings.topic.position.right')}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
key="delete"
className="text-danger"
onClick={() => {
onDelete()
}}>
<DeleteIcon size={14} className="lucide-custom text-danger" />
<span className="text-danger">{t('common.delete')}</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</>
<Dropdown
menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<SessionListItem
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={isEditing ? undefined : onPress}
onDoubleClick={() => startEdit(session.name ?? '')}
title={session.name ?? session.id}
onContextMenu={() => setTargetSession(session)}
style={{
borderRadius: 'var(--list-item-border-radius)',
cursor: isEditing ? 'default' : 'pointer'
}}>
{isPending && !isActive && <PendingIndicator />}
{isFulfilled && !isActive && <FulfilledIndicator />}
<SessionNameContainer>
{isEditing ? (
<SessionEditInput
ref={inputRef}
value={editValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
style={{ opacity: isSaving ? 0.5 : 1 }}
/>
) : (
<>
<SessionName>
<SessionLabel session={session} />
</SessionName>
<DeleteButton />
</>
)}
</SessionNameContainer>
</SessionListItem>
</Dropdown>
)
}
const SessionListItem = styled.div`
padding: 7px 12px;
border-radius: var(--list-item-border-radius);
font-size: 13px;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: pointer;
width: calc(var(--assistants-width) - 20px);
margin-bottom: 8px;
.menu {
opacity: 0;
color: var(--color-text-3);
}
&:hover {
background-color: var(--color-list-item-hover);
transition: background-color 0.1s;
.menu {
opacity: 1;
}
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
.menu {
opacity: 1;
&:hover {
color: var(--color-text-2);
}
}
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
box-shadow: none;
}
}
`
const SessionNameContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
height: 20px;
justify-content: space-between;
`
const SessionName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
position: relative;
`
const SessionEditInput = styled.input`
background: var(--color-background);
border: none;
color: var(--color-text-1);
font-size: 13px;
font-family: inherit;
padding: 2px 6px;
width: 100%;
outline: none;
padding: 0;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 20px;
min-height: 20px;
.anticon {
font-size: 12px;
}
`
const PendingIndicator = styled.div.attrs({
className: 'animation-pulse'
})`
--pulse-size: 5px;
width: 5px;
height: 5px;
position: absolute;
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-status-warning);
`
const FulfilledIndicator = styled.div.attrs({
className: 'animation-pulse'
})`
--pulse-size: 5px;
width: 5px;
height: 5px;
position: absolute;
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-status-success);
`
export default memo(SessionItem)

View File

@ -1,4 +1,4 @@
import { Alert, Spinner } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions'
@ -11,13 +11,14 @@ import {
setSessionWaitingAction
} from '@renderer/store/runtime'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Alert, Spin } from 'antd'
import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddButton from './AddButton'
import SessionItem from './SessionItem'
import { ListContainer } from './shared'
// const logger = loggerService.withContext('SessionsTab')
@ -88,16 +89,18 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full items-center justify-center">
<Spinner size="lg" />
<Spin />
</motion.div>
)
}
if (error) return <Alert color="danger" content={t('agent.session.get.error.failed')} />
if (error) {
return <Alert type="error" message={t('agent.session.get.error.failed')} showIcon style={{ margin: 10 }} />
}
return (
<ListContainer className="sessions-tab">
<AddButton onPress={createDefaultSession} className="mb-2" isDisabled={creatingSession}>
<Container className="sessions-tab">
<AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
{t('agent.session.add.title')}
</AddButton>
{/* h-9 */}
@ -119,8 +122,15 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
/>
)}
</DynamicVirtualList>
</ListContainer>
</Container>
)
}
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 12px 10px;
overflow-x: hidden;
`
export default memo(Sessions)

View File

@ -1,5 +1,5 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { cn } from '@heroui/react'
import { cn } from '@renderer/utils'
import { Tooltip } from 'antd'
import type { FC, ReactNode } from 'react'

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
@ -18,7 +17,7 @@ import store from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setGenerating } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import { removeSpecialCharactersForFileName } from '@renderer/utils'
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
import {
exportMarkdownToJoplin,
@ -54,15 +53,6 @@ import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import AddButton from './AddButton'
import {
ListContainer,
ListItem,
ListItemEditInput,
ListItemName,
ListItemNameContainer,
MenuButton,
StatusIndicator
} from './shared'
interface Props {
assistant: Assistant
@ -83,6 +73,8 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics)
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
@ -497,107 +489,255 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
const singlealone = topicPosition === 'right' && position === 'right'
return (
<ListContainer className="topics-tab">
<AddButton onPress={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2">
{t('chat.add.topic.title')}
</AddButton>
<DraggableVirtualList list={sortedTopics} onUpdate={updateTopics} className="overflow-y-auto overflow-x-hidden">
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
<DraggableVirtualList
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '11px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<>
<AddButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
{t('chat.add.topic.title')}
</AddButton>
<div className="my-1"></div>
</>
}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<ListItem
onContextMenu={() => setTargetTopic(topic)}
className={cn(
isActive ? 'active' : undefined,
singlealone ? 'singlealone' : undefined,
editingTopicId === topic.id && topicEdit.isEditing ? 'cursor-default' : 'cursor-pointer',
showTopicTime ? 'rounded-2xl' : 'rounded-[var(--list-item-border-radius)]'
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
style={{
borderRadius,
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
}}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
<TopicNameContainer>
{editingTopicId === topic.id && topicEdit.isEditing ? (
<TopicEditInput
ref={topicEdit.inputRef}
value={topicEdit.editValue}
onChange={topicEdit.handleInputChange}
onKeyDown={topicEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}>
{topicName}
</TopicName>
)}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}>
{isPending(topic.id) && !isActive && <StatusIndicator variant="pending" />}
{isFulfilled(topic.id) && !isActive && <StatusIndicator variant="fulfilled" />}
<ListItemNameContainer>
{editingTopicId === topic.id && topicEdit.isEditing ? (
<ListItemEditInput
ref={topicEdit.inputRef}
value={topicEdit.editValue}
onChange={topicEdit.handleInputChange}
onKeyDown={topicEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<ListItemName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
mouseLeaveDelay={0}
title={
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{topicName}
</ListItemName>
)}
{!topic.pinned && (
<Tooltip
placement="bottom"
mouseEnterDelay={0.7}
mouseLeaveDelay={0}
title={
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
)}
</MenuButton>
</Tooltip>
)}
{topic.pinned && (
<MenuButton className="pin">
<PinIcon size={14} color="var(--color-text-3)" />
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
)}
</MenuButton>
)}
</ListItemNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
</Tooltip>
)}
{showTopicTime && (
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>
{topic.pinned && (
<MenuButton className="pin">
<PinIcon size={14} color="var(--color-text-3)" />
</MenuButton>
)}
</ListItem>
</Dropdown>
)
}}
</DraggableVirtualList>
</ListContainer>
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
)}
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
</TopicListItem>
</Dropdown>
)
}}
</DraggableVirtualList>
)
}
const TopicListItem = styled.div`
padding: 7px 12px;
border-radius: var(--list-item-border-radius);
font-size: 13px;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: pointer;
width: calc(var(--assistants-width) - 20px);
.menu {
opacity: 0;
color: var(--color-text-3);
}
&:hover {
background-color: var(--color-list-item-hover);
transition: background-color 0.1s;
.menu {
opacity: 1;
}
}
&.active {
background-color: var(--color-list-item);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
.menu {
opacity: 1;
&:hover {
color: var(--color-text-2);
}
}
}
&.singlealone {
border-radius: 0 !important;
&:hover {
background-color: var(--color-background-soft);
}
&.active {
border-left: 2px solid var(--color-primary);
box-shadow: none;
}
}
`
const TopicNameContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
height: 20px;
justify-content: space-between;
`
const TopicName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
position: relative;
will-change: background-position, width;
--color-shimmer-mid: var(--color-text-1);
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
&.shimmer {
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: shimmer 3s linear infinite;
}
&.typing {
display: block;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
white-space: nowrap;
overflow: hidden;
animation: typewriter 0.5s steps(40, end);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes typewriter {
from {
width: 0;
}
to {
width: 100%;
}
}
`
const TopicEditInput = styled.input`
background: var(--color-background);
border: none;
color: var(--color-text-1);
font-size: 13px;
font-family: inherit;
padding: 2px 6px;
width: 100%;
outline: none;
padding: 0;
`
const PendingIndicator = styled.div.attrs({
className: 'animation-pulse'
})`
--pulse-size: 5px;
width: 5px;
height: 5px;
position: absolute;
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-status-warning);
`
const FulfilledIndicator = styled.div.attrs({
className: 'animation-pulse'
})`
--pulse-size: 5px;
width: 5px;
height: 5px;
position: absolute;
left: 3px;
top: 15px;
border-radius: 50%;
background-color: var(--color-status-success);
`
const TopicPromptText = styled.div`
color: var(--color-text-2);
font-size: 12px;
@ -614,3 +754,15 @@ const TopicTime = styled.div`
color: var(--color-text-3);
font-size: 11px;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 20px;
min-height: 20px;
.anticon {
font-size: 12px;
}
`

View File

@ -1,6 +1,6 @@
import { useDisclosure } from '@heroui/react'
import AddAssistantOrAgentPopup from '@renderer/components/Popups/AddAssistantOrAgentPopup'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import AgentModalPopup from '@renderer/components/Popups/agent/AgentModal'
import { useApiServer } from '@renderer/hooks/useApiServer'
import { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { AgentEntity, Assistant, Topic } from '@renderer/types'
@ -18,20 +18,8 @@ interface UnifiedAddButtonProps {
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setActiveAssistant, setActiveAgentId }) => {
const { t } = useTranslation()
const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure()
const dispatch = useAppDispatch()
const handleAddButtonClick = () => {
AddAssistantOrAgentPopup.show({
onSelect: (type) => {
if (type === 'assistant') {
onCreateAssistant()
} else if (type === 'agent') {
onAgentModalOpen()
}
}
})
}
const { apiServerRunning, startApiServer } = useApiServer()
const afterCreate = useCallback(
(a: AgentEntity) => {
@ -58,12 +46,24 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setAct
[dispatch, setActiveAgentId, setActiveAssistant]
)
const handleAddButtonClick = async () => {
AddAssistantOrAgentPopup.show({
onSelect: (type) => {
if (type === 'assistant') {
onCreateAssistant()
}
if (type === 'agent') {
!apiServerRunning && startApiServer()
AgentModalPopup.show({ afterSubmit: afterCreate })
}
}
})
}
return (
<div className="mb-1">
<AddButton onPress={handleAddButtonClick} className="-mt-[1px] mb-[2px]">
{t('chat.add.assistant.title')}
</AddButton>
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} afterSubmit={afterCreate} />
<div className="-mt-[4px] mb-[6px]">
<AddButton onClick={handleAddButtonClick}>{t('chat.add.assistant.title')}</AddButton>
</div>
)
}

View File

@ -1,131 +0,0 @@
import { cn } from '@heroui/react'
import type { ComponentPropsWithoutRef, ComponentPropsWithRef } from 'react'
import { useMemo } from 'react'
import styled from 'styled-components'
export const ListItem = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
return (
<div
className={cn(
'mb-2 flex w-[calc(var(--assistants-width)-20px)] cursor-pointer flex-col justify-between rounded-lg px-3 py-2 text-sm',
'transition-colors duration-100',
'hover:bg-[var(--color-list-item-hover)]',
'[.active]:bg-[var(--color-list-item)] [.active]:shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]',
'[&_.menu]:text-[var(--color-text-3)] [&_.menu]:opacity-0',
'hover:[&_.menu]:opacity-100',
'[.active]:[&_.menu]:opacity-100 [.active]:[&_.menu]:hover:text-[var(--color-text-2)]',
'[.singlealone.active]:border-[var(--color-primary)] [.singlealone.active]:shadow-none [.singlealone]:rounded-none [.singlealone]:border-transparent [.singlealone]:border-l-2 [.singlealone]:hover:bg-[var(--color-background-soft)]',
className
)}
{...props}>
{children}
</div>
)
}
export const ListItemNameContainer = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
return (
<div className={cn('flex h-5 flex-row items-center justify-between gap-1', className)} {...props}>
{children}
</div>
)
}
// This component involves complex animations and will not be migrated for now.
export const ListItemName = styled.div`
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
position: relative;
will-change: background-position, width;
--color-shimmer-mid: var(--color-text-1);
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
&.shimmer {
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: shimmer 3s linear infinite;
}
&.typing {
display: block;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
white-space: nowrap;
overflow: hidden;
animation: typewriter 0.5s steps(40, end);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes typewriter {
from {
width: 0;
}
to {
width: 100%;
}
}
`
export const ListItemEditInput = ({ className, ...props }: ComponentPropsWithRef<'input'>) => {
return (
<input
className={cn(
'w-full border-none bg-[var(--color-background)] p-0 font-inherit text-[var(--color-text-1)] text-sm outline-none',
className
)}
{...props}
/>
)
}
export const ListContainer = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
return (
<div className={cn('flex h-full w-full flex-col p-2', className)} {...props}>
{children}
</div>
)
}
export const MenuButton = ({ children, className, ...props }: ComponentPropsWithoutRef<'div'>) => {
return (
<div className={cn('menu', 'flex min-h-5 min-w-5 flex-row items-center justify-center', className)} {...props}>
{children}
</div>
)
}
export const StatusIndicator = ({ variant }: { variant: 'pending' | 'fulfilled' }) => {
const colors = useMemo(() => {
switch (variant) {
case 'pending':
return {
wave: 'bg-warning-400',
back: 'bg-warning-500'
}
case 'fulfilled':
return {
wave: 'bg-success-400',
back: 'bg-success-500'
}
}
}, [variant])
return (
<div className="absolute top-4 left-1 flex size-1">
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', colors.wave)} />
<span className={cn('relative inline-flex size-1 rounded-full bg-warning-500', colors.back)} />
</div>
)
}

View File

@ -1,4 +1,3 @@
import { Alert, Skeleton } from '@heroui/react'
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
@ -12,6 +11,7 @@ import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store
import type { Assistant, Topic } from '@renderer/types'
import type { Tab } from '@renderer/types/chat'
import { classNames, getErrorMessage, uuid } from '@renderer/utils'
import { Alert, Skeleton } from 'antd'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -166,16 +166,17 @@ const HomeTabs: FC<Props> = ({
)}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && !sessionError && (
<Skeleton isLoaded={!isSessionLoading} className="h-full">
<Skeleton loading={isSessionLoading} active style={{ height: '100%', padding: '16px' }}>
<SessionSettingsTab session={session} update={updateSession} />
</Skeleton>
)}
{tab === 'settings' && isSessionView && sessionError && (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<Alert
color="danger"
title={t('agent.session.get.error.failed')}
type="error"
message={t('agent.session.get.error.failed')}
description={getErrorMessage(sessionError)}
style={{ padding: '10px 15px' }}
/>
</div>
)}

View File

@ -1,4 +1,3 @@
import { BreadcrumbItem, Breadcrumbs, cn } from '@heroui/react'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import { useActiveAgent } from '@renderer/hooks/agents/useActiveAgent'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
@ -7,15 +6,18 @@ import { useRuntime } from '@renderer/hooks/useRuntime'
import type { AgentEntity, AgentSessionEntity, ApiModel, Assistant } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { t } from 'i18next'
import { Folder } from 'lucide-react'
import { ChevronRight, Folder } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { useCallback } from 'react'
import { twMerge } from 'tailwind-merge'
import { AgentSettingsPopup, SessionSettingsPopup } from '../../settings/AgentSettings'
import { AgentLabel, SessionLabel } from '../../settings/AgentSettings/shared'
import SelectAgentBaseModelButton from './SelectAgentBaseModelButton'
import SelectModelButton from './SelectModelButton'
const cn = (...inputs: any[]) => twMerge(inputs)
interface Props {
assistant: Assistant
}
@ -40,43 +42,53 @@ const ChatNavbarContent: FC<Props> = ({ assistant }) => {
{activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'session' && activeAgent && (
<HorizontalScrollContainer className="ml-2 flex-initial">
<Breadcrumbs classNames={{ base: 'flex', list: 'flex-nowrap' }}>
<BreadcrumbItem
onPress={() => AgentSettingsPopup.show({ agentId: activeAgent.id })}
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<div className="flex flex-nowrap items-center gap-2">
{/* Agent Label */}
<div
className="flex h-full cursor-pointer items-center"
onClick={() => AgentSettingsPopup.show({ agentId: activeAgent.id })}>
<AgentLabel
agent={activeAgent}
classNames={{ name: 'max-w-40 text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</BreadcrumbItem>
</div>
{activeSession && (
<BreadcrumbItem
onPress={() =>
SessionSettingsPopup.show({
agentId: activeAgent.id,
sessionId: activeSession.id
})
}
classNames={{ base: 'self-stretch', item: 'h-full' }}>
<SessionLabel session={activeSession} className="max-w-40 text-xs" />
</BreadcrumbItem>
)}
{activeSession && (
<BreadcrumbItem>
<>
{/* Separator */}
<ChevronRight className="h-4 w-4 text-gray-400" />
{/* Session Label */}
<div
className="flex h-full cursor-pointer items-center"
onClick={() =>
SessionSettingsPopup.show({
agentId: activeAgent.id,
sessionId: activeSession.id
})
}>
<SessionLabel session={activeSession} className="max-w-40 text-xs" />
</div>
{/* Separator */}
<ChevronRight className="h-4 w-4 text-gray-400" />
{/* Model Button */}
<SelectAgentBaseModelButton
agentBase={activeSession}
onSelect={async (model) => {
await handleUpdateModel(model)
}}
/>
</BreadcrumbItem>
)}
{activeAgent && activeSession && (
<BreadcrumbItem>
{/* Separator */}
<ChevronRight className="h-4 w-4 text-gray-400" />
{/* Workspace Meta */}
<SessionWorkspaceMeta agent={activeAgent} session={activeSession} />
</BreadcrumbItem>
</>
)}
</Breadcrumbs>
</div>
</HorizontalScrollContainer>
)}
</>

View File

@ -1,28 +1,59 @@
import { Button } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import { agentModelFilter } from '@renderer/config/models'
import { useApiModel } from '@renderer/hooks/agents/useModel'
import { getProviderNameById } from '@renderer/services/ProviderService'
import type { AgentBaseWithId, ApiModel } from '@renderer/types'
import { isAgentSessionEntity } from '@renderer/types'
import { isAgentEntity } from '@renderer/types'
import { getModelFilterByAgentType } from '@renderer/utils/agentSession'
import { apiModelAdapter } from '@renderer/utils/model'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import { ChevronsUpDown } from 'lucide-react'
import type { FC } from 'react'
import type { CSSProperties, FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
agentBase: AgentBaseWithId
onSelect: (model: ApiModel) => Promise<void>
isDisabled?: boolean
/** Custom className for the button */
className?: string
/** Custom inline styles for the button (merged with default styles) */
buttonStyle?: CSSProperties
/** Custom button size */
buttonSize?: ButtonProps['size']
/** Custom avatar size */
avatarSize?: number
/** Custom font size */
fontSize?: number
/** Custom icon size */
iconSize?: number
/** Custom className for the inner container (e.g., for justify-between) */
containerClassName?: string
}
const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isDisabled }) => {
const SelectAgentBaseModelButton: FC<Props> = ({
agentBase: agent,
onSelect,
isDisabled,
className,
buttonStyle,
buttonSize = 'small',
avatarSize = 20,
fontSize = 12,
iconSize = 14,
containerClassName
}) => {
const { t } = useTranslation()
const model = useApiModel({ id: agent?.model })
const apiFilter = isAgentEntity(agent) ? getModelFilterByAgentType(agent.type) : undefined
const apiFilter = isAgentEntity(agent)
? getModelFilterByAgentType(agent.type)
: isAgentSessionEntity(agent)
? getModelFilterByAgentType(agent.agent_type)
: undefined
if (!agent) return null
@ -35,20 +66,31 @@ const SelectAgentBaseModelButton: FC<Props> = ({ agentBase: agent, onSelect, isD
const providerName = model?.provider ? getProviderNameById(model.provider) : model?.provider_name
// Merge default styles with custom styles
const mergedStyle: CSSProperties = {
borderRadius: 20,
fontSize,
padding: 2,
...buttonStyle
}
return (
<Button
size="sm"
variant="light"
className="nodrag h-[28px] rounded-2xl px-1"
onPress={onSelectModel}
isDisabled={isDisabled}>
<div className="flex items-center gap-1.5 overflow-x-hidden">
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={20} />
<span className="truncate text-[var(--color-text)]">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span>
size={buttonSize}
type="text"
className={className}
style={mergedStyle}
onClick={onSelectModel}
disabled={isDisabled}>
<div className={containerClassName || 'flex w-full items-center gap-1.5'}>
<div className="flex flex-1 items-center gap-1.5 overflow-x-hidden">
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={avatarSize} />
<span className="truncate text-[var(--color-text)]">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span>
</div>
<ChevronsUpDown size={iconSize} color="var(--color-icon)" />
</div>
<ChevronsUpDown size={14} color="var(--color-icon)" />
</Button>
)
}

View File

@ -1,6 +1,5 @@
import { SyncOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import UpdateDialog from '@renderer/components/UpdateDialog'
import UpdateDialogPopup from '@renderer/components/Popups/UpdateDialogPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button } from 'antd'
@ -12,7 +11,6 @@ const UpdateAppButton: FC = () => {
const { update } = useRuntime()
const { autoCheckUpdate } = useSettings()
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
if (!update) {
return null
@ -22,19 +20,21 @@ const UpdateAppButton: FC = () => {
return null
}
const handleOpenUpdateDialog = () => {
UpdateDialogPopup.show({ releaseInfo: update.info || null })
}
return (
<Container>
<UpdateButton
className="nodrag"
onClick={onOpen}
onClick={handleOpenUpdateDialog}
icon={<SyncOutlined />}
color="orange"
variant="outlined"
size="small">
{t('button.update_available')}
</UpdateButton>
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={update.info || null} />
</Container>
)
}

View File

@ -1,5 +1,6 @@
import { Button, Input } from '@heroui/react'
import { loggerService } from '@logger'
import type { InputRef } from 'antd'
import { Button, Input } from 'antd'
import type { WebviewTag } from 'electron'
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import type { FC } from 'react'
@ -22,7 +23,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
const [query, setQuery] = useState('')
const [matchCount, setMatchCount] = useState(0)
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<InputRef>(null)
const focusFrameRef = useRef<number | null>(null)
const lastAppIdRef = useRef<string>(appId)
const attachedWebviewRef = useRef<WebviewTag | null>(null)
@ -315,19 +316,13 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
ref={inputRef}
autoFocus
value={query}
onValueChange={setQuery}
spellCheck={'false'}
onChange={(e) => setQuery(e.target.value)}
spellCheck={false}
placeholder={t('common.search')}
size="sm"
radius="sm"
variant="flat"
classNames={{
base: 'w-[240px]',
inputWrapper:
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
input: 'text-small focus:outline-none focus-visible:outline-none',
innerWrapper: 'gap-0'
}}
size="small"
variant="borderless"
className="w-[240px]"
style={{ height: '32px' }}
/>
<span
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
@ -339,38 +334,32 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
</span>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToPrevious}
isDisabled={disableNavigation}
size="small"
type="text"
onClick={goToPrevious}
disabled={disableNavigation}
aria-label="Previous match"
className="text-default-500 hover:text-default-900">
<ChevronUp size={16} />
</Button>
icon={<ChevronUp size={16} className="w-6" />}
className="text-default-500 hover:text-default-900"
/>
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={goToNext}
isDisabled={disableNavigation}
size="small"
type="text"
onClick={goToNext}
disabled={disableNavigation}
aria-label="Next match"
className="text-default-500 hover:text-default-900">
<ChevronDown size={16} />
</Button>
icon={<ChevronDown size={16} className="w-6" />}
className="text-default-500 hover:text-default-900"
/>
<div className="h-4 w-px bg-default-200" />
<Button
size="sm"
variant="light"
radius="full"
isIconOnly
onPress={closeSearch}
size="small"
type="text"
onClick={closeSearch}
aria-label={t('common.close')}
className="text-default-500 hover:text-default-900">
<X size={16} />
</Button>
icon={<X size={16} className="w-6" />}
className="text-default-500 hover:text-default-900"
/>
</div>
)
}

View File

@ -1,4 +1,3 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/react'
import { loggerService } from '@logger'
import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout'
@ -6,7 +5,7 @@ import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { findNode } from '@renderer/services/NotesTreeService'
import { Dropdown, Input, Tooltip } from 'antd'
import { Breadcrumb, Dropdown, Input, Tooltip } from 'antd'
import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -191,42 +190,43 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
</HStack>
<NavbarCenter style={{ flex: 1, minWidth: 0 }}>
<BreadcrumbsContainer>
<Breadcrumbs style={{ borderRadius: 0 }}>
{breadcrumbItems.map((item, index) => {
<Breadcrumb
separator={'>'}
items={breadcrumbItems.map((item, index) => {
const isLastItem = index === breadcrumbItems.length - 1
const isCurrentNote = isLastItem && !item.isFolder
return (
<BreadcrumbItem key={item.key} isCurrent={isLastItem}>
{isCurrentNote ? (
<TitleInputWrapper>
<TitleInput
ref={titleInputRef}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
size="small"
variant="borderless"
style={{
fontSize: 'inherit',
padding: 0,
height: 'auto',
lineHeight: 'inherit'
}}
/>
</TitleInputWrapper>
) : (
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && !isLastItem}>
{item.title}
</BreadcrumbTitle>
)}
</BreadcrumbItem>
)
})}
</Breadcrumbs>
return {
title: (
<div key={item.key} className="flex">
{isCurrentNote ? (
<TitleInputWrapper>
<TitleInput
ref={titleInputRef}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
size="small"
variant="borderless"
style={{
fontSize: 'inherit',
padding: 0,
height: 'auto',
lineHeight: 'inherit'
}}
/>
</TitleInputWrapper>
) : (
<BreadcrumbTitle
onClick={() => handleBreadcrumbClick(item)}
$clickable={item.isFolder && !isLastItem}>
{item.title}
</BreadcrumbTitle>
)}
</div>
)
}
})}></Breadcrumb>
</BreadcrumbsContainer>
</NavbarCenter>
<NavbarRight style={{ paddingRight: 0 }}>
@ -347,13 +347,6 @@ export const BreadcrumbsContainer = styled.div`
max-width: none !important;
}
/* 覆盖 HeroUI BreadcrumbItem 的样式 */
& li:last-child [data-slot="item"] {
flex: 1 !important;
width: 100% !important;
max-width: none !important;
}
/* 更强的样式覆盖 */
& li:last-child * {
max-width: none !important;

View File

@ -1,9 +1,9 @@
import { Select, SelectItem } from '@heroui/react'
import { ProviderAvatarPrimitive } from '@renderer/components/ProviderAvatar'
import { getProviderLogo } from '@renderer/config/providers'
import ImageStorage from '@renderer/services/ImageStorage'
import { getProviderNameById } from '@renderer/services/ProviderService'
import type { Provider } from '@types'
import { Select } from 'antd'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
@ -54,46 +54,46 @@ const ProviderSelect: FC<ProviderSelectProps> = ({ provider, options, onChange,
return (
<Select
selectedKeys={[provider.id]}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as string
onChange(selectedKey)
}}
style={style}
className={`w-full ${className || ''}`}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
value={provider.id}
onChange={onChange}
style={{ width: '100%', ...style }}
className={className}
options={providerOptions}
labelRender={(props) => {
const providerId = props.value as string
const providerName = providerOptions.find((opt) => opt.value === providerId)?.label || ''
return (
<div className="flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={item.key as string}
providerName={item.textValue || ''}
logoSrc={getProviderLogoSrc(item.key as string)}
providerId={providerId}
providerName={providerName}
logoSrc={getProviderLogoSrc(providerId)}
size={16}
/>
</div>
<span>{item.textValue}</span>
<span>{providerName}</span>
</div>
))
}}>
{providerOptions.map((providerOption) => (
<SelectItem
key={providerOption.value}
textValue={providerOption.label}
startContent={
)
}}
optionRender={(option) => {
const providerId = option.value as string
const providerName = option.label as string
return (
<div className="flex items-center gap-2">
<div className="flex h-4 w-4 items-center justify-center">
<ProviderAvatarPrimitive
providerId={providerOption.value}
providerName={providerOption.label}
logoSrc={getProviderLogoSrc(providerOption.value)}
providerId={providerId}
providerName={providerName}
logoSrc={getProviderLogoSrc(providerId)}
size={16}
/>
</div>
}>
{providerOption.label}
</SelectItem>
))}
</Select>
<span>{providerName}</span>
</div>
)
}}
/>
)
}

View File

@ -1,8 +1,7 @@
import { GithubOutlined } from '@ant-design/icons'
import { useDisclosure } from '@heroui/react'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { HStack } from '@renderer/components/Layout'
import UpdateDialog from '@renderer/components/UpdateDialog'
import UpdateDialogPopup from '@renderer/components/Popups/UpdateDialogPopup'
import { APP_NAME, AppLogo } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
@ -15,7 +14,6 @@ import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { UpgradeChannel } from '@shared/config/constant'
import { Avatar, Button, Progress, Radio, Row, Switch, Tag, Tooltip } from 'antd'
import type { UpdateInfo } from 'builder-util-runtime'
import { debounce } from 'lodash'
import { Bug, Building2, Github, Globe, Mail, Rss } from 'lucide-react'
import { BadgeQuestionMark } from 'lucide-react'
@ -31,8 +29,6 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitl
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const [isPortable, setIsPortable] = useState(false)
const [updateDialogInfo, setUpdateDialogInfo] = useState<UpdateInfo | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const { t } = useTranslation()
const { autoCheckUpdate, setAutoCheckUpdate, testPlan, setTestPlan, testChannel, setTestChannel } = useSettings()
const { theme } = useTheme()
@ -48,8 +44,7 @@ const AboutSettings: FC = () => {
if (update.downloaded) {
// Open update dialog directly in renderer
setUpdateDialogInfo(update.info || null)
onOpen()
UpdateDialogPopup.show({ releaseInfo: update.info || null })
return
}
@ -342,9 +337,6 @@ const AboutSettings: FC = () => {
<Button onClick={debug}>{t('settings.about.debug.open')}</Button>
</SettingRow>
</SettingGroup>
{/* Update Dialog */}
<UpdateDialog isOpen={isOpen} onClose={onClose} releaseInfo={updateDialogInfo} />
</SettingContainer>
)
}

View File

@ -1,6 +1,6 @@
import { Button, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Button, Tooltip } from 'antd'
import { Plus } from 'lucide-react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -65,21 +65,21 @@ export const AccessibleDirsSetting = ({ base, update }: AccessibleDirsSettingPro
<SettingsItem>
<SettingsTitle
actions={
<Tooltip content={t('agent.session.accessible_paths.add')}>
<Button variant="light" size="sm" startContent={<Plus />} isIconOnly onPress={addAccessiblePath} />
<Tooltip title={t('agent.session.accessible_paths.add')}>
<Button type="text" icon={<Plus size={16} />} shape="circle" onClick={addAccessiblePath} />
</Tooltip>
}>
{t('agent.session.accessible_paths.label')}
</SettingsTitle>
<ul className="flex flex-col gap-2">
<ul className="flex flex-col">
{base.accessible_paths.map((path) => (
<li
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-2 py-1">
<span className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm" title={path}>
<li key={path} className="flex items-center justify-between gap-2 py-1">
<span
className="w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[var(--color-text-2)] text-sm"
title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
<Button size="small" type="text" danger onClick={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</li>

View File

@ -1,4 +1,3 @@
import { Input, Tooltip } from '@heroui/react'
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import type {
@ -8,6 +7,7 @@ import type {
UpdateAgentBaseForm
} from '@renderer/types'
import { AgentConfigurationSchema } from '@renderer/types'
import { InputNumber, Tooltip } from 'antd'
import { Info } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -31,34 +31,33 @@ const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.p
export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, update }) => {
const { t } = useTranslation()
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [maxTurnsInput, setMaxTurnsInput] = useState<string>(String(defaultConfiguration.max_turns))
const [maxTurnsInput, setMaxTurnsInput] = useState<number>(defaultConfiguration.max_turns)
useEffect(() => {
if (!agentBase) {
setConfiguration(defaultConfiguration)
setMaxTurnsInput(String(defaultConfiguration.max_turns))
setMaxTurnsInput(defaultConfiguration.max_turns)
return
}
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed)
setMaxTurnsInput(String(parsed.max_turns))
setMaxTurnsInput(parsed.max_turns)
}, [agentBase])
const commitMaxTurns = useCallback(() => {
if (!agentBase) return
const parsedValue = Number.parseInt(maxTurnsInput, 10)
if (!Number.isFinite(parsedValue)) {
setMaxTurnsInput(String(configuration.max_turns))
if (!Number.isFinite(maxTurnsInput)) {
setMaxTurnsInput(configuration.max_turns)
return
}
const sanitized = Math.max(1, parsedValue)
const sanitized = Math.max(1, maxTurnsInput)
if (sanitized === configuration.max_turns) {
setMaxTurnsInput(String(configuration.max_turns))
setMaxTurnsInput(configuration.max_turns)
return
}
const next: AgentConfigurationState = { ...configuration, max_turns: sanitized }
setConfiguration(next)
setMaxTurnsInput(String(sanitized))
setMaxTurnsInput(sanitized)
update({ id: agentBase.id, configuration: next } satisfies UpdateAgentBaseForm)
}, [agentBase, configuration, maxTurnsInput, update])
@ -71,27 +70,23 @@ export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, u
<SettingsItem divider={false}>
<SettingsTitle
actions={
<Tooltip content={t('agent.settings.advance.maxTurns.description')} placement="right">
<Tooltip title={t('agent.settings.advance.maxTurns.description')} placement="left">
<Info size={16} className="text-foreground-400" />
</Tooltip>
}>
{t('agent.settings.advance.maxTurns.label')}
</SettingsTitle>
<div className="flex w-full flex-col gap-2">
<Input
type="number"
<div className="my-2 flex w-full flex-col gap-2">
<InputNumber
min={1}
value={maxTurnsInput}
onValueChange={setMaxTurnsInput}
onChange={(value) => setMaxTurnsInput(value ?? 1)}
onBlur={commitMaxTurns}
onKeyDown={(event) => {
if (event.key === 'Enter') {
commitMaxTurns()
}
}}
onPressEnter={commitMaxTurns}
aria-label={t('agent.settings.advance.maxTurns.label')}
style={{ width: '100%' }}
/>
<span className="text-foreground-500 text-xs">{t('agent.settings.advance.maxTurns.helper')}</span>
<span className="mt-1 text-foreground-500 text-xs">{t('agent.settings.advance.maxTurns.helper')}</span>
</div>
</SettingsItem>
</SettingsContainer>

View File

@ -1,7 +1,8 @@
import { Alert, Spinner } from '@heroui/react'
import { Center } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { Alert, Spin } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -71,18 +72,25 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
const ModalContent = () => {
if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
<Center flex={1}>
<Spin />
</Center>
)
}
if (error) {
return (
<Center flex={1}>
<Alert type="error" message={t('agent.get.error.failed')} />
</Center>
)
}
if (!agent) {
return null
}
return (
<div className="flex w-full flex-1">
<LeftMenu>

View File

@ -1,5 +1,5 @@
import { Textarea } from '@heroui/react'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import TextArea from 'antd/es/input/TextArea'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -24,11 +24,12 @@ export const DescriptionSetting = ({ base, update }: DescriptionSettingProps) =>
if (!base) return null
return (
<SettingsItem>
<SettingsItem divider={false}>
<SettingsTitle>{t('common.description')}</SettingsTitle>
<Textarea
<TextArea
value={description}
onValueChange={setDescription}
onChange={(e) => setDescription(e.target.value)}
rows={4}
onBlur={() => {
if (description !== base.description) {
updateDesc(description)

View File

@ -1,10 +1,10 @@
import { Avatar } from '@heroui/react'
import { getAgentTypeAvatar } from '@renderer/config/agent'
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import type { GetAgentResponse, GetAgentSessionResponse } from '@renderer/types'
import { isAgentEntity } from '@renderer/types'
import { Avatar } from 'antd'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
@ -42,7 +42,7 @@ const EssentialSettings: FC<EssentialSettingsProps> = ({ agentBase, update, show
<SettingsItem inline>
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
<div className="flex items-center gap-2">
<Avatar src={getAgentTypeAvatar(agentBase.type)} className="h-6 w-6 text-lg" />
<Avatar size={24} src={getAgentTypeAvatar(agentBase.type)} className="h-6 w-6 text-lg" />
<span>{(agentBase?.name ?? agentBase?.type) ? getAgentTypeLabel(agentBase.type) : ''}</span>
</div>
</SettingsItem>

View File

@ -1,5 +1,5 @@
import { Input } from '@heroui/react'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Input } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -25,14 +25,13 @@ export const NameSetting = ({ base, update }: NameSettingsProps) => {
<Input
placeholder={t('common.agent_one') + t('common.name')}
value={name}
size="sm"
onValueChange={(value) => setName(value)}
onChange={(e) => setName(e.target.value)}
onBlur={() => {
if (name !== base.name) {
updateName(name)
}
}}
className="max-w-80 flex-1"
className="max-w-70 flex-1"
/>
</SettingsItem>
)

View File

@ -1,13 +1,14 @@
import { Card, CardBody, Tab, Tabs } from '@heroui/react'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentFunctionUnion } from '@renderer/types/agent'
import { Card, Segmented } from 'antd'
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { InstalledPluginsList } from './components/InstalledPluginsList'
import { PluginBrowser } from './components/PluginBrowser'
import { SettingsContainer } from './shared'
interface PluginSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse
@ -16,6 +17,7 @@ interface PluginSettingsProps {
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<string>('available')
// Fetch available plugins
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
@ -54,61 +56,89 @@ const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
[uninstall, t]
)
return (
<SettingsContainer className="pr-0">
<Tabs
aria-label="Plugin settings tabs"
classNames={{
base: 'w-full',
tabList: 'w-full',
panel: 'w-full flex-1 overflow-hidden'
}}>
<Tab key="available" title={t('agent.settings.plugins.available.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-1 pr-2">
{errorAvailable ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorAvailable}
</p>
</CardBody>
</Card>
) : (
<PluginBrowser
agentId={agentBase.id}
agents={agents}
commands={commands}
skills={skills}
installedPlugins={plugins}
onInstall={handleInstall}
onUninstall={handleUninstall}
loading={loadingAvailable || installing || uninstalling}
/>
)}
</div>
</Tab>
const segmentOptions = useMemo(() => {
return [
{
value: 'available',
label: t('agent.settings.plugins.available.title')
},
{
value: 'installed',
label: t('agent.settings.plugins.installed.title')
}
]
}, [t])
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}>
<div className="flex h-full flex-col overflow-y-auto pt-4 pr-2">
{errorInstalled ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorInstalled}
</p>
</CardBody>
</Card>
) : (
<InstalledPluginsList
plugins={plugins}
onUninstall={handleUninstall}
loading={loadingInstalled || uninstalling}
/>
)}
</div>
</Tab>
</Tabs>
</SettingsContainer>
const renderContent = useMemo(() => {
if (activeTab === 'available') {
return (
<div className="flex h-full flex-col overflow-y-auto pt-4 pr-2">
{errorAvailable ? (
<Card variant="borderless">
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorAvailable}
</p>
</Card>
) : (
<PluginBrowser
agentId={agentBase.id}
agents={agents}
commands={commands}
skills={skills}
installedPlugins={plugins}
onInstall={handleInstall}
onUninstall={handleUninstall}
loading={loadingAvailable || installing || uninstalling}
/>
)}
</div>
)
}
return (
<div className="flex h-full flex-col overflow-y-auto pt-4 pr-2">
{errorInstalled ? (
<Card className="bg-danger-50 dark:bg-danger-900/20">
<p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorInstalled}
</p>
</Card>
) : (
<InstalledPluginsList
plugins={plugins}
onUninstall={handleUninstall}
loading={loadingInstalled || uninstalling}
/>
)}
</div>
)
}, [
activeTab,
agentBase.id,
agents,
commands,
errorAvailable,
errorInstalled,
handleInstall,
handleUninstall,
installing,
loadingAvailable,
loadingInstalled,
plugins,
skills,
t,
uninstalling
])
return (
<Scrollbar>
<div className="flex flex-col gap-2">
<div className="flex justify-center">
<Segmented options={segmentOptions} value={activeTab} onChange={(value) => setActiveTab(value as string)} />
</div>
{renderContent}
</div>
</Scrollbar>
)
}

View File

@ -1,7 +1,8 @@
import { Alert, Spinner } from '@heroui/react'
import { Center } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { Alert, Spin } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -68,15 +69,21 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab
const ModalContent = () => {
if (isLoading) {
// TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return (
<div>
<Alert color="danger" title={t('agent.get.error.failed')} />
</div>
<Center flex={1}>
<Spin />
</Center>
)
}
if (error) {
return (
<Center flex={1}>
<Alert type="error" message={t('agent.get.error.failed')} />
</Center>
)
}
return (
<div className="flex w-full flex-1">
<LeftMenu>

View File

@ -1,4 +1,3 @@
import { Alert, Card, CardBody, CardHeader, Chip, Input, Switch } from '@heroui/react'
import { permissionModeCards } from '@renderer/config/agent'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
@ -13,8 +12,9 @@ import type {
UpdateAgentSessionFunction
} from '@renderer/types'
import { AgentConfigurationSchema } from '@renderer/types'
import { Modal } from 'antd'
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react'
import { Modal, Tag } from 'antd'
import { Alert, Card, Input, Switch } from 'antd'
import { ShieldAlert, Wrench } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -272,47 +272,50 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
const showCaution = card.caution
return (
<Card
<div
key={card.mode}
isPressable={!disabled}
isDisabled={disabled || isUpdatingMode}
shadow="none"
onPress={() => handleSelectPermissionMode(card.mode)}
className={`border ${
isSelected ? 'border-primary' : 'border-default-200'
} ${disabled ? 'opacity-60' : ''}`}>
<CardHeader className="flex items-start justify-between gap-2">
<div className="flex flex-col">
<span className="text-left font-semibold text-sm">{t(card.titleKey, card.titleFallback)}</span>
<span className="text-left text-foreground-500 text-xs">
className={`flex flex-col gap-3 overflow-hidden rounded-lg border p-4 transition-colors ${
isSelected
? 'border-primary bg-primary-50/30 dark:bg-primary-950/20'
: 'border-default-200 hover:bg-default-50 dark:hover:bg-default-900/20'
} ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
onClick={() => !disabled && handleSelectPermissionMode(card.mode)}>
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="whitespace-normal break-words text-left font-semibold text-sm">
{t(card.titleKey, card.titleFallback)}
</span>
<span className="whitespace-normal break-words text-left text-foreground-500 text-xs">
{t(card.descriptionKey, card.descriptionFallback)}
</span>
</div>
{disabled ? (
<Chip color="warning" size="sm" variant="flat">
{t('common.coming_soon', 'Coming soon')}
</Chip>
) : isSelected ? (
<Chip color="primary" size="sm" variant="flat" startContent={<ShieldCheck size={14} />}>
{t('common.selected', 'Selected')}
</Chip>
) : null}
</CardHeader>
<CardBody className="gap-2 text-left text-xs">
<span className="text-foreground-600">{t(card.behaviorKey, card.behaviorFallback)}</span>
{showCaution ? (
<div className="flex items-center gap-1">
<ShieldAlert className="text-danger-600" size={24} />
<span className="text-danger-600">
{disabled && <Tag color="warning">{t('common.coming_soon', 'Coming soon')}</Tag>}
{isSelected && !disabled && (
<Tag color="success">
<div className="flex items-center gap-1">
<span>{t('common.selected', 'Selected')}</span>
</div>
</Tag>
)}
</div>
{/* Body */}
<div className="flex flex-col gap-2">
<span className="text-foreground-600 text-xs">{t(card.behaviorKey, card.behaviorFallback)}</span>
{showCaution && (
<div className="flex items-start gap-2 rounded-md bg-danger-50 p-2 dark:bg-danger-950/30">
<ShieldAlert className="mt-0.5 flex-shrink-0 text-danger-600" size={16} />
<span className="text-danger-600 text-xs">
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</span>
</div>
) : null}
</CardBody>
</Card>
)}
</div>
</div>
)
})}
</div>
@ -324,23 +327,31 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
</SettingsTitle>
<div className="flex flex-col gap-4">
<Alert
color="warning"
title={t(
'agent.settings.tooling.preapproved.warning.title',
'Pre-approved tools run without manual review.'
)}
description={t(
'agent.settings.tooling.preapproved.warning.description',
'Enable only tools you trust. Mode defaults are highlighted automatically.'
)}
showIcon
type="warning"
style={{ padding: '8px 12px' }}
message={
<span className="font-semibold text-sm text-warning">
{t('agent.settings.tooling.preapproved.warning.title', 'Pre-approved tools run without manual review.')}
</span>
}
description={
<span className="text-warning text-xs">
{t(
'agent.settings.tooling.preapproved.warning.description',
'Enable only tools you trust. Mode defaults are highlighted automatically.'
)}
</span>
}
/>
<Input
isClearable
allowClear
value={searchTerm}
onValueChange={setSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('agent.settings.tooling.preapproved.search', 'Search tools')}
aria-label={t('agent.settings.tooling.preapproved.search', 'Search tools')}
className="w-full"
size={'large'}
/>
<div className="flex flex-col gap-3">
{filteredTools.length === 0 ? (
@ -352,54 +363,71 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
const isAuto = autoToolIds.includes(tool.id)
const isApproved = approvedToolIds.includes(tool.id)
return (
<Card key={tool.id} shadow="none" className="border border-default-200">
<CardHeader className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium text-sm">{tool.name}</span>
{tool.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{tool.description}</span>
) : null}
<div className="flex flex-wrap items-center gap-2">
{isAuto ? (
<Chip size="sm" color="primary" variant="flat">
{t('agent.settings.tooling.preapproved.autoBadge', 'Added by mode')}
</Chip>
) : null}
{tool.type === 'mcp' ? (
<Chip size="sm" color="secondary" variant="flat">
{t('agent.settings.tooling.preapproved.mcpBadge', 'MCP tool')}
</Chip>
) : null}
{tool.requirePermissions ? (
<Chip size="sm" color="warning" variant="flat">
{t(
'agent.settings.tooling.preapproved.requiresApproval',
'Requires approval when disabled'
)}
</Chip>
<Card
key={tool.id}
className="border border-default-200"
title={
<div className="flex items-start justify-between gap-3 py-2">
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium text-sm">{tool.name}</span>
{tool.description ? (
<span className="line-clamp-2 whitespace-normal text-foreground-500 text-xs">
{tool.description}
</span>
) : null}
<div className="flex flex-wrap items-center gap-2">
{isAuto ? (
<Tag color="success">
{t('agent.settings.tooling.preapproved.autoBadge', 'Added by mode')}
</Tag>
) : null}
{tool.type === 'mcp' ? (
<Tag color="default">{t('agent.settings.tooling.preapproved.mcpBadge', 'MCP tool')}</Tag>
) : null}
{tool.requirePermissions ? (
<Tag color="warning">
{t(
'agent.settings.tooling.preapproved.requiresApproval',
'Requires approval when disabled'
)}
</Tag>
) : null}
</div>
</div>
<Switch
aria-label={t('agent.settings.tooling.preapproved.toggle', {
defaultValue: `Toggle ${tool.name}`,
name: tool.name
})}
checked={isApproved}
disabled={isAuto || isUpdatingTools}
size="small"
onChange={(checked) => handleToggleTool(tool.id, checked)}
/>
</div>
<Switch
aria-label={t('agent.settings.tooling.preapproved.toggle', {
defaultValue: `Toggle ${tool.name}`,
name: tool.name
})}
isSelected={isApproved}
isDisabled={isAuto || isUpdatingTools}
size="sm"
onValueChange={(value) => handleToggleTool(tool.id, value)}
/>
</CardHeader>
}
styles={{
header: {
paddingLeft: '12px',
paddingRight: '12px',
borderBottom: 'none'
},
body: {
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '0px',
paddingBottom: '0px'
}
}}>
{isAuto ? (
<CardBody className="py-0 pb-3">
<div className="py-0 pb-3">
<span className="text-foreground-400 text-xs">
{t(
'agent.settings.tooling.preapproved.autoDescription',
'This tool is auto-approved by the current permission mode.'
)}
</span>
</CardBody>
</div>
) : null}
</Card>
)
@ -427,26 +455,43 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
{availableServers.map((server) => {
const isSelected = selectedMcpIds.includes(server.id)
return (
<Card key={server.id} shadow="none" className="border border-default-200">
<CardHeader className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-sm">{server.name}</span>
{server.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{server.description}</span>
) : null}
<Card
key={server.id}
className="border border-default-200"
title={
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-sm">{server.name}</span>
{server.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{server.description}</span>
) : null}
</div>
<Switch
aria-label={t('agent.settings.tooling.mcp.toggle', {
defaultValue: `Toggle ${server.name}`,
name: server.name
})}
checked={isSelected}
size="small"
disabled={!server.isActive || isUpdatingMcp}
onChange={(checked) => handleToggleMcp(server.id, checked)}
/>
</div>
<Switch
aria-label={t('agent.settings.tooling.mcp.toggle', {
defaultValue: `Toggle ${server.name}`,
name: server.name
})}
isSelected={isSelected}
size="sm"
isDisabled={!server.isActive || isUpdatingMcp}
onValueChange={(value) => handleToggleMcp(server.id, value)}
/>
</CardHeader>
</Card>
}
styles={{
header: {
paddingLeft: '12px',
paddingRight: '12px',
borderBottom: 'none'
},
body: {
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '0px',
paddingBottom: '0px'
}
}}
/>
)
})}
</div>
@ -462,33 +507,47 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
<SettingsItem divider={false}>
<SettingsTitle>{t('agent.settings.tooling.steps.review.title', 'Step 3 · Review')}</SettingsTitle>
<Card shadow="none" className="border border-default-200">
<CardBody className="flex flex-col gap-2 text-sm">
<Card
className="border border-default-200"
styles={{
header: {
paddingLeft: '12px',
paddingRight: '12px',
borderBottom: 'none'
},
body: {
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '12px',
paddingBottom: '12px'
}
}}>
<div className="flex flex-col gap-2 text-sm">
<div className="flex flex-wrap gap-3">
<Chip variant="flat" color="primary">
<Tag color="success">
{t('agent.settings.tooling.review.mode', {
defaultValue: `Mode: ${selectedMode}`,
mode: selectedMode
})}
</Chip>
<Chip variant="flat" color="default">
</Tag>
<Tag color="default">
{t('agent.settings.tooling.review.autoTools', {
defaultValue: `Auto: ${autoCount}`,
count: autoCount
})}
</Chip>
<Chip variant="flat" color="success">
</Tag>
<Tag color="success">
{t('agent.settings.tooling.review.customTools', {
defaultValue: `Custom: ${customCount}`,
count: customCount
})}
</Chip>
<Chip variant="flat" color="warning">
</Tag>
<Tag color="warning">
{t('agent.settings.tooling.review.mcp', {
defaultValue: `MCP: ${agentSummary.mcps}`,
count: agentSummary.mcps
})}
</Chip>
</Tag>
</div>
<span className="text-foreground-500 text-xs">
{t(
@ -496,7 +555,7 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
'Changes save automatically. Adjust the steps above any time to fine-tune permissions.'
)}
</span>
</CardBody>
</div>
</Card>
</SettingsItem>
</SettingsContainer>

View File

@ -1,6 +1,7 @@
import { Button, Chip, Skeleton, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@heroui/react'
import type { InstalledPlugin } from '@renderer/types/plugin'
import { Trash2 } from 'lucide-react'
import type { TableProps } from 'antd'
import { Button, Skeleton, Table as AntTable, Tag } from 'antd'
import { Dot, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -33,10 +34,10 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
if (loading) {
return (
<div className="space-y-2">
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<Skeleton className="h-12 w-full rounded-lg" />
<div className="flex flex-col space-y-2">
<Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
<Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
<Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
</div>
)
}
@ -50,50 +51,61 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
)
}
return (
<Table aria-label="Installed plugins table" removeWrapper>
<TableHeader>
<TableColumn>{t('plugins.name')}</TableColumn>
<TableColumn>{t('plugins.type')}</TableColumn>
<TableColumn>{t('plugins.category')}</TableColumn>
<TableColumn align="end">{t('plugins.actions')}</TableColumn>
</TableHeader>
<TableBody>
{plugins.map((plugin) => (
<TableRow key={plugin.filename}>
<TableCell>
<div className="flex flex-col">
<span className="font-semibold text-small">{plugin.metadata.name}</span>
{plugin.metadata.description && (
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
)}
</div>
</TableCell>
<TableCell>
<Chip size="sm" variant="flat" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
</TableCell>
<TableCell>
<Chip size="sm" variant="dot">
{plugin.metadata.category}
</Chip>
</TableCell>
<TableCell>
<Button
size="sm"
color="danger"
variant="light"
isIconOnly
onPress={() => handleUninstall(plugin)}
isLoading={uninstallingPlugin === plugin.filename}
isDisabled={loading}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
const columns: TableProps<InstalledPlugin>['columns'] = [
{
title: t('plugins.name'),
dataIndex: 'name',
key: 'name',
render: (_: any, plugin: InstalledPlugin) => (
<div className="flex flex-col">
<span className="font-semibold text-small">{plugin.metadata.name}</span>
{plugin.metadata.description && (
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
)}
</div>
)
},
{
title: t('plugins.type'),
dataIndex: 'type',
key: 'type',
align: 'center',
render: (type: string) => <Tag color={type === 'agent' ? 'magenta' : 'purple'}>{type}</Tag>
},
{
title: t('plugins.category'),
dataIndex: 'category',
key: 'category',
align: 'center',
render: (_: any, plugin: InstalledPlugin) => (
<Tag
icon={<Dot size={14} strokeWidth={8} />}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{plugin.metadata.category}
</Tag>
)
},
{
title: t('plugins.actions'),
key: 'actions',
align: 'center',
render: (_: any, plugin: InstalledPlugin) => (
<Button
danger
type="text"
onClick={() => handleUninstall(plugin)}
loading={uninstallingPlugin === plugin.filename}
disabled={loading}
icon={<Trash2 className="h-4 w-4" />}
/>
)
}
]
return <AntTable columns={columns} dataSource={plugins} size="small" />
}

View File

@ -1,5 +1,6 @@
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Tab, Tabs } from '@heroui/react'
import type { InstalledPlugin, PluginMetadata } from '@renderer/types/plugin'
import { Button as AntButton, Dropdown as AntDropdown, Input as AntInput, Tabs as AntTabs } from 'antd'
import type { ItemType } from 'antd/es/menu/interface'
import { Filter, Search } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -42,6 +43,7 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
const [selectedPlugin, setSelectedPlugin] = useState<PluginMetadata | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const observerTarget = useRef<HTMLDivElement>(null)
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false)
// Combine all plugins based on active type
const allPlugins = useMemo(() => {
@ -92,6 +94,68 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
return filteredPlugins.slice(0, displayCount)
}, [filteredPlugins, displayCount])
const pluginCategoryMenuItems = useMemo(() => {
const isSelected = (category: string): boolean =>
category === 'all' ? selectedCategories.length === 0 : selectedCategories.includes(category)
const handleClick = (category: string) => {
if (category === 'all') {
handleCategoryChange(new Set(['all']))
} else {
const newKeys = selectedCategories.includes(category)
? new Set(selectedCategories.filter((c) => c !== category))
: new Set([...selectedCategories, category])
handleCategoryChange(newKeys)
}
}
const itemLabel = (category: string) => (
<div className="flex flex-row justify-between">
{category}
{isSelected(category) && <span className="ml-2 text-primary text-sm"></span>}
</div>
)
return [
{
key: 'all',
title: t('plugins.all_categories'),
label: itemLabel('all'),
onClick: () => handleClick('all')
},
...allCategories.map(
(category) =>
({
key: category,
title: category,
label: itemLabel(category),
onClick: () => handleClick(category)
}) satisfies ItemType
)
]
}, [allCategories, selectedCategories, t])
const pluginTypeTabItems = useMemo(
() => [
{
key: 'all',
label: t('plugins.all_types')
},
{
key: 'agent',
label: t('plugins.agents')
},
{
key: 'command',
label: t('plugins.commands')
},
{
key: 'skill',
label: t('plugins.skills')
}
],
[t]
)
const hasMore = displayCount < filteredPlugins.length
// Reset display count when filters change
@ -169,74 +233,37 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
return (
<div className="flex flex-col gap-4">
{/* Search and Filter */}
<div className="relative flex gap-0">
<Input
<div className="flex gap-2">
<AntInput
placeholder={t('plugins.search_placeholder')}
value={searchQuery}
onValueChange={handleSearchChange}
startContent={<Search className="h-4 w-4 text-default-400" />}
isClearable
size="md"
className="flex-1"
classNames={{
inputWrapper: 'pr-12'
}}
onChange={(e) => handleSearchChange(e.target.value)}
prefix={<Search className="h-4 w-4 text-default-400" />}
/>
<Dropdown placement="bottom-end" classNames={{ content: 'max-h-60 overflow-y-auto p-0' }}>
<DropdownTrigger>
<Button
isIconOnly
variant={selectedCategories.length > 0 ? 'flat' : 'light'}
color={selectedCategories.length > 0 ? 'primary' : 'default'}
size="sm"
className="-translate-y-1/2 absolute top-1/2 right-2 z-10">
<Filter className="h-4 w-4" />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Category filter"
closeOnSelect={false}
className="max-h-60 overflow-y-auto"
items={[
{ key: 'all', label: t('plugins.all_categories') },
...allCategories.map((category) => ({ key: category, label: category }))
]}>
{(item) => {
const isSelected =
item.key === 'all' ? selectedCategories.length === 0 : selectedCategories.includes(item.key)
return (
<DropdownItem
key={item.key}
textValue={item.label}
onPress={() => {
if (item.key === 'all') {
handleCategoryChange(new Set(['all']))
} else {
const newKeys = selectedCategories.includes(item.key)
? new Set(selectedCategories.filter((c) => c !== item.key))
: new Set([...selectedCategories, item.key])
handleCategoryChange(newKeys)
}
}}
className={isSelected ? 'bg-primary-50' : ''}>
{item.label}
{isSelected && <span className="ml-2 text-primary text-sm"></span>}
</DropdownItem>
)
}}
</DropdownMenu>
</Dropdown>
<AntDropdown
menu={{ items: pluginCategoryMenuItems }}
trigger={['click']}
open={filterDropdownOpen}
placement="bottomRight"
onOpenChange={setFilterDropdownOpen}>
<AntButton
variant={selectedCategories.length > 0 ? 'filled' : 'outlined'}
color={selectedCategories.length > 0 ? 'primary' : 'default'}
size="middle"
icon={<Filter className="h-4 w-4" color="var(--color-text-2)" />}
/>
</AntDropdown>
</div>
{/* Type Tabs */}
<div className="-mt-3 flex justify-center">
<Tabs selectedKey={activeType} onSelectionChange={handleTypeChange} variant="underlined">
<Tab key="all" title={t('plugins.all_types')} />
<Tab key="agent" title={t('plugins.agents')} />
<Tab key="command" title={t('plugins.commands')} />
<Tab key="skill" title={t('plugins.skills')} />
</Tabs>
<div className="-mb-3 flex w-full justify-center">
<AntTabs
activeKey={activeType}
onChange={handleTypeChange}
items={pluginTypeTabItems}
className="w-full"
size="small"
/>
</div>
{/* Result Count */}

View File

@ -1,5 +1,5 @@
import { Button, Card, CardBody, CardFooter, CardHeader, Chip, Spinner } from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Button, Card, Spin, Tag } from 'antd'
import { upperFirst } from 'lodash'
import { Download, Trash2 } from 'lucide-react'
import type { FC } from 'react'
@ -17,73 +17,73 @@ export interface PluginCardProps {
export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => {
const { t } = useTranslation()
const getTypeTagColor = () => {
if (plugin.type === 'agent') return 'blue'
if (plugin.type === 'skill') return 'green'
return 'default'
}
return (
<Card
className="flex h-full w-full cursor-pointer flex-col border-[0.5px] border-default-200"
isPressable
shadow="none"
onPress={onClick}>
<CardHeader className="flex flex-col items-start gap-2 pb-2">
className="flex h-full w-full cursor-pointer flex-col"
onClick={onClick}
styles={{
body: { display: 'flex', flexDirection: 'column', height: '100%', padding: '16px' }
}}>
<div className="flex flex-col items-start gap-2 pb-2">
<div className="flex w-full items-center justify-between gap-2">
<h3 className="truncate font-medium text-small">{plugin.name}</h3>
<Chip
size="sm"
variant="solid"
color={plugin.type === 'agent' ? 'primary' : plugin.type === 'skill' ? 'success' : 'secondary'}
className="h-4 min-w-0 flex-shrink-0 px-0.5 text-xs">
<h3 className="truncate font-medium text-sm">{plugin.name}</h3>
<Tag color={getTypeTagColor()} className="m-0 text-xs">
{upperFirst(plugin.type)}
</Chip>
</Tag>
</div>
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
</CardHeader>
<Tag className="m-0">{plugin.category}</Tag>
</div>
<CardBody className="flex-1 py-2">
<p className="line-clamp-3 text-default-500 text-small">{plugin.description || t('plugins.no_description')}</p>
<div className="flex-1 py-2">
<p className="line-clamp-3 text-gray-500 text-sm">{plugin.description || t('plugins.no_description')}</p>
{plugin.tags && plugin.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered" className="text-tiny">
<Tag key={tag} bordered className="text-xs">
{tag}
</Chip>
</Tag>
))}
</div>
)}
</CardBody>
</div>
<CardFooter className="pt-2">
<div className="pt-2">
{installed ? (
<Button
color="danger"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
danger
type="primary"
size="small"
icon={loading ? <Spin size="small" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onUninstall()
}}
isDisabled={loading}
fullWidth>
disabled={loading}
block>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
variant="flat"
size="sm"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
type="primary"
size="small"
icon={loading ? <Spin size="small" /> : <Download className="h-4 w-4" />}
onClick={(e) => {
e.stopPropagation()
onInstall()
}}
isDisabled={loading}
fullWidth>
disabled={loading}
block>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</CardFooter>
</div>
</Card>
)
}

View File

@ -1,16 +1,6 @@
import {
Button,
Chip,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea
} from '@heroui/react'
import type { PluginMetadata } from '@renderer/types/plugin'
import { Download, Edit, Save, Trash2, X } from 'lucide-react'
import { Button, Input, Modal, Spin, Tag } from 'antd'
import { Dot, Download, Edit, Save, Trash2, X } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
@ -122,198 +112,201 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
const modalContent = (
<Modal
isOpen={isOpen}
onClose={onClose}
size="2xl"
scrollBehavior="inside"
classNames={{
wrapper: 'z-[9999]'
}}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
centered
open={isOpen}
onCancel={onClose}
styles={{
body: {
maxHeight: '60vh',
overflowY: 'auto'
}
}}
style={{
width: '70%'
}}
title={
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h2 className="font-bold text-xl">{plugin.name}</h2>
<Chip size="sm" variant="solid" color={plugin.type === 'agent' ? 'primary' : 'secondary'}>
{plugin.type}
</Chip>
<Tag color={plugin.type === 'agent' ? 'magenta' : 'purple'}>{plugin.type}</Tag>
</div>
<div className="flex items-center gap-2">
<Chip size="sm" variant="dot" color="default">
<Tag
icon={<Dot size={14} strokeWidth={8} />}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{plugin.category}
</Chip>
{plugin.version && (
<Chip size="sm" variant="bordered">
v{plugin.version}
</Chip>
)}
</Tag>
{plugin.version && <Tag>v{plugin.version}</Tag>}
</div>
</ModalHeader>
<ModalBody>
{/* Description */}
{plugin.description && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Description</h3>
<p className="text-default-600 text-small">{plugin.description}</p>
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Author</h3>
<p className="text-default-600 text-small">{plugin.author}</p>
</div>
)}
{/* Tools (for agents) */}
{plugin.tools && plugin.tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Allowed Tools (for commands) */}
{plugin.allowed_tools && plugin.allowed_tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.allowed_tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat">
{tool}
</Chip>
))}
</div>
</div>
)}
{/* Tags */}
{plugin.tags && plugin.tags.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tags</h3>
<div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered">
{tag}
</Chip>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Metadata</h3>
<div className="space-y-1 text-small">
<div className="flex justify-between">
<span className="text-default-500">File:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.filename}</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Size:</span>
<span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Source:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span>
</div>
{plugin.installedAt && (
<div className="flex justify-between">
<span className="text-default-500">Installed:</span>
<span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-small">Content</h3>
{installed && !contentLoading && !contentError && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
size="sm"
variant="flat"
color="danger"
startContent={<X className="h-3 w-3" />}
onPress={handleCancelEdit}
isDisabled={saving}>
Cancel
</Button>
<Button
size="sm"
color="primary"
startContent={saving ? <Spinner size="sm" color="current" /> : <Save className="h-3 w-3" />}
onPress={handleSave}
isDisabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</>
) : (
<Button size="sm" variant="flat" startContent={<Edit className="h-3 w-3" />} onPress={handleEdit}>
Edit
</Button>
)}
</div>
)}
</div>
{contentLoading ? (
<div className="flex items-center justify-center py-4">
<Spinner size="sm" />
</div>
) : contentError ? (
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
) : isEditing ? (
<Textarea
value={editedContent}
onValueChange={setEditedContent}
minRows={20}
classNames={{
input: 'font-mono text-tiny'
}}
/>
) : (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-default-100 p-3 font-mono text-tiny">
{content}
</pre>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</div>
}
footer={
<div className="flex flex-row justify-end gap-4">
<Button type="text" onClick={onClose}>
{t('common.close')}
</Button>
{installed ? (
<Button
color="danger"
variant="flat"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />}
onPress={onUninstall}
isDisabled={loading}>
danger
variant="filled"
icon={loading ? <Spin size="small" /> : <Trash2 className="h-4 w-4" />}
iconPosition={'start'}
onClick={onUninstall}
disabled={loading}>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button>
) : (
<Button
color="primary"
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onPress={onInstall}
isDisabled={loading}>
variant="solid"
icon={loading ? <Spin size="small" /> : <Download className="h-4 w-4" />}
iconPosition={'start'}
onClick={onInstall}
disabled={loading}>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</ModalFooter>
</ModalContent>
</div>
}>
<div>
{/* Description */}
{plugin.description && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Description</h3>
<p className="text-default-600 text-small">{plugin.description}</p>
</div>
)}
{/* Author */}
{plugin.author && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Author</h3>
<p className="text-default-600 text-small">{plugin.author}</p>
</div>
)}
{/* Tools (for agents) */}
{plugin.tools && plugin.tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.tools.map((tool) => (
<Tag key={tool}>{tool}</Tag>
))}
</div>
</div>
)}
{/* Allowed Tools (for commands) */}
{plugin.allowed_tools && plugin.allowed_tools.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
<div className="flex flex-wrap gap-1">
{plugin.allowed_tools.map((tool) => (
<Tag key={tool}>{tool}</Tag>
))}
</div>
</div>
)}
{/* Tags */}
{plugin.tags && plugin.tags.length > 0 && (
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Tags</h3>
<div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="mb-4">
<h3 className="mb-2 font-semibold text-small">Metadata</h3>
<div className="space-y-1 text-small">
<div className="flex justify-between">
<span className="text-default-500">File:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.filename}</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Size:</span>
<span className="text-default-600">{(plugin.size / 1024).toFixed(2)} KB</span>
</div>
<div className="flex justify-between">
<span className="text-default-500">Source:</span>
<span className="font-mono text-default-600 text-tiny">{plugin.sourcePath}</span>
</div>
{plugin.installedAt && (
<div className="flex justify-between">
<span className="text-default-500">Installed:</span>
<span className="text-default-600">{new Date(plugin.installedAt).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Content */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-small">Content</h3>
{installed && !contentLoading && !contentError && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
danger
variant="filled"
icon={<X className="h-3 w-3" />}
iconPosition="start"
onClick={handleCancelEdit}
disabled={saving}>
{t('common.cancel')}
</Button>
<Button
color="primary"
variant="filled"
icon={saving ? <Spin size="small" /> : <Save className="h-3 w-3" />}
onClick={handleSave}
disabled={saving}>
{t('common.save')}
</Button>
</>
) : (
<Button variant="filled" icon={<Edit className="h-3 w-3" />} onClick={handleEdit}>
{t('common.edit')}
</Button>
)}
</div>
)}
</div>
{contentLoading ? (
<div className="flex items-center justify-center py-4">
<Spin size="small" />
</div>
) : contentError ? (
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
) : isEditing ? (
<Input.TextArea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
autoSize={{ minRows: 20 }}
classNames={{
textarea: 'font-mono text-tiny'
}}
/>
) : (
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-default-100 p-3 font-mono text-tiny">
{content}
</pre>
)}
</div>
</div>
</Modal>
)

View File

@ -1,7 +1,7 @@
import { cn } from '@heroui/react'
import EmojiIcon from '@renderer/components/EmojiIcon'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import type { AgentEntity, AgentSessionEntity } from '@renderer/types'
import { cn } from '@renderer/utils'
import { Menu, Modal } from 'antd'
import type { ReactNode } from 'react'
import React from 'react'

View File

@ -6,8 +6,6 @@ import {
WifiOutlined,
YuqueOutlined
} from '@ant-design/icons'
import { Button } from '@heroui/button'
import { Switch } from '@heroui/switch'
import DividerWithText from '@renderer/components/DividerWithText'
import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons'
import { HStack } from '@renderer/components/Layout'
@ -24,7 +22,7 @@ import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/setting
import type { AppInfo } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { occupiedDirs } from '@shared/config/constant'
import { Progress, Typography } from 'antd'
import { Button, Progress, Switch, Typography } from 'antd'
import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon, Sparkle } from 'lucide-react'
import type { FC } from 'react'
import { useEffect, useState } from 'react'
@ -295,16 +293,11 @@ const DataSettings: FC = () => {
<div>
<MigrationPathRow style={{ marginTop: '20px', flexDirection: 'row', alignItems: 'center' }}>
<Switch
defaultSelected={shouldCopyData}
onValueChange={(checked) => {
shouldCopyData = checked
}}
size="sm">
<span style={{ fontWeight: 'normal', fontSize: '14px' }}>
{t('settings.data.app_data.copy_data_option')}
</span>
</Switch>
defaultChecked={shouldCopyData}
onChange={(checked) => (shouldCopyData = checked)}
style={{ marginRight: '8px' }}
title={t('settings.data.app_data.copy_data_option')}
/>
<MigrationPathLabel style={{ fontWeight: 'normal', fontSize: '14px' }}>
{t('settings.data.app_data.copy_data_option')}
</MigrationPathLabel>
@ -614,10 +607,10 @@ const DataSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button variant="ghost" size="sm" onPress={BackupPopup.show} startContent={<SaveIcon size={14} />}>
<Button onClick={BackupPopup.show} icon={<SaveIcon size={14} />}>
{t('settings.general.backup.button')}
</Button>
<Button variant="ghost" size="sm" onPress={RestorePopup.show} startContent={<FolderOpen size={14} />}>
<Button onClick={RestorePopup.show} icon={<FolderOpen size={14} />}>
{t('settings.general.restore.button')}
</Button>
</HStack>
@ -625,7 +618,7 @@ const DataSettings: FC = () => {
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.data.backup.skip_file_data_title')}</SettingRowTitle>
<Switch isSelected={skipBackupFile} onValueChange={onSkipBackupFilesChange} size="sm" />
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
</SettingRow>
<SettingRow>
<SettingHelpText>{t('settings.data.backup.skip_file_data_help')}</SettingHelpText>
@ -634,11 +627,7 @@ const DataSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.data.export_to_phone.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between">
<Button
variant="ghost"
size="sm"
onPress={ExportToPhoneLanPopup.show}
startContent={<WifiOutlined />}>
<Button onClick={ExportToPhoneLanPopup.show} icon={<WifiOutlined size={14} />}>
{t('settings.data.export_to_phone.lan.title')}
</Button>
</HStack>
@ -657,9 +646,7 @@ const DataSettings: FC = () => {
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} />
<HStack gap="5px" style={{ marginLeft: '8px' }}>
<Button variant="ghost" size="sm" onClick={handleSelectAppDataPath}>
{t('settings.data.app_data.select')}
</Button>
<Button onClick={handleSelectAppDataPath}>{t('settings.data.app_data.select')}</Button>
</HStack>
</PathRow>
</SettingRow>
@ -672,7 +659,7 @@ const DataSettings: FC = () => {
</PathText>
<StyledIcon onClick={() => handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} />
<HStack gap="5px" style={{ marginLeft: '8px' }}>
<Button variant="ghost" size="sm" onClick={() => handleOpenPath(appInfo?.logsPath)}>
<Button onClick={() => handleOpenPath(appInfo?.logsPath)}>
{t('settings.data.app_logs.button')}
</Button>
</HStack>
@ -682,9 +669,7 @@ const DataSettings: FC = () => {
<SettingRow>
<SettingRowTitle>{t('settings.data.app_knowledge.label')}</SettingRowTitle>
<HStack alignItems="center" gap="5px">
<Button variant="ghost" size="sm" onClick={handleRemoveAllFiles}>
{t('settings.data.app_knowledge.button.delete')}
</Button>
<Button onClick={handleRemoveAllFiles}>{t('settings.data.app_knowledge.button.delete')}</Button>
</HStack>
</SettingRow>
<SettingDivider />
@ -694,16 +679,14 @@ const DataSettings: FC = () => {
{cacheSize && <CacheText>({cacheSize}MB)</CacheText>}
</SettingRowTitle>
<HStack gap="5px">
<Button variant="ghost" size="sm" onClick={handleClearCache}>
{t('settings.data.clear_cache.button')}
</Button>
<Button onClick={handleClearCache}>{t('settings.data.clear_cache.button')}</Button>
</HStack>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
<HStack gap="5px">
<Button variant="ghost" size="sm" onPress={reset} color="danger">
<Button onClick={reset} danger>
{t('settings.general.reset.title')}
</Button>
</HStack>

View File

@ -1,4 +1,3 @@
import { Alert, Skeleton } from '@heroui/react'
import { loggerService } from '@logger'
import { ErrorTag } from '@renderer/components/Tags/ErrorTag'
import { isMac, isWin } from '@renderer/config/constant'
@ -6,7 +5,7 @@ import { useOcrProviders } from '@renderer/hooks/useOcrProvider'
import type { ImageOcrProvider, OcrProvider } from '@renderer/types'
import { BuiltinOcrProviderIds, isImageOcrProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils'
import { Select } from 'antd'
import { Alert, Select, Skeleton } from 'antd'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
@ -70,27 +69,39 @@ const OcrImageSettings = ({ setProvider }: Props) => {
<SettingRowTitle>{t('settings.tool.ocr.image_provider')}</SettingRowTitle>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{!platformSupport && isSystem && <ErrorTag message={t('settings.tool.ocr.error.not_system')} />}
<Skeleton isLoaded={!isLoading}>
{!error && (
<Select
value={imageProvider.id}
style={{ width: '200px' }}
onChange={(id: string) => setImageProvider(id)}
options={options}
/>
)}
{error && (
<Alert
color="danger"
title={t('ocr.error.provider.get_providers')}
description={getErrorMessage(error)}
/>
)}
</Skeleton>
<OcrProviderSelector
isLoading={isLoading}
error={error}
value={imageProvider.id}
options={options}
onChange={setImageProvider}
/>
</div>
</SettingRow>
</>
)
}
type OcrProviderSelectorProps = {
isLoading: boolean
error: any
value: string
options: Array<{ value: string; label: string }>
onChange: (id: string) => void
}
const OcrProviderSelector = ({ isLoading, error, value, options, onChange }: OcrProviderSelectorProps) => {
const { t } = useTranslation()
if (isLoading) {
return <Skeleton.Input active style={{ width: '200px', height: '32px' }} />
}
if (error) {
return <Alert type="error" message={t('ocr.error.provider.get_providers')} description={getErrorMessage(error)} />
}
return <Select value={value} style={{ width: '200px' }} onChange={onChange} options={options} />
}
export default OcrImageSettings

View File

@ -1,11 +1,10 @@
// TODO: Refactor this component to use HeroUI
import { useTheme } from '@renderer/context/ThemeProvider'
import { useApiServer } from '@renderer/hooks/useApiServer'
import type { RootState } from '@renderer/store'
import { useAppDispatch } from '@renderer/store'
import { setApiServerApiKey, setApiServerPort } from '@renderer/store/settings'
import { formatErrorMessage } from '@renderer/utils/error'
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd'
import { Alert, Button, Input, InputNumber, Tooltip, Typography } from 'antd'
import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
@ -86,6 +85,10 @@ const ApiServerSettings: FC = () => {
)}
</HeaderSection>
{!apiServerRunning && (
<Alert type="warning" message={t('agent.warning.enable_server')} style={{ marginBottom: 10 }} showIcon />
)}
{/* Server Control Panel with integrated configuration */}
<ServerControlPanel $status={apiServerRunning}>
<StatusSection>

Some files were not shown because too many files have changed in this diff Show More