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. - **Keep it clear**: Write code that is easy to read, maintain, and explain.
- **Match the house style**: Reuse existing patterns, naming, and conventions. - **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. - **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`. - **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. - **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. - **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. - **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc.
- **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. - **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces.
- **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. - **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state.
- **UI Components**: HeroUI (`@heroui/*`) for all new UI elements.
### Logging ### Logging
```typescript ```typescript

View File

@ -147,7 +147,6 @@
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", "@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", "@hello-pangea/dnd": "^18.0.1",
"@heroui/react": "^2.8.3",
"@kangfenmao/keyv-storage": "^0.1.0", "@kangfenmao/keyv-storage": "^0.1.0",
"@langchain/community": "^1.0.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", "@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", "striptags": "^3.2.0",
"styled-components": "^6.1.11", "styled-components": "^6.1.11",
"swr": "^2.3.6", "swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tar": "^7.4.3", "tar": "^7.4.3",
"tiny-pinyin": "^1.3.2", "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) { export function setupOpenAPIDocumentation(app: Express) {

View File

@ -365,6 +365,16 @@ class ClaudeCodeService implements AgentServiceInterface {
type: 'chunk', type: 'chunk',
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 { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
import { ToastPortal } from './components/ToastPortal'
import TopViewContainer from './components/TopView' import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider' import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider' import { CodeStyleProvider } from './context/CodeStyleProvider'
import { HeroUIProvider } from './context/HeroUIProvider'
import { NotificationProvider } from './context/NotificationProvider' import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager' import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider' import { ThemeProvider } from './context/ThemeProvider'
@ -34,7 +32,6 @@ function App(): React.ReactElement {
return ( return (
<Provider store={store}> <Provider store={store}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<HeroUIProvider>
<StyleSheetManager> <StyleSheetManager>
<ThemeProvider> <ThemeProvider>
<AntdProvider> <AntdProvider>
@ -50,8 +47,6 @@ function App(): React.ReactElement {
</AntdProvider> </AntdProvider>
</ThemeProvider> </ThemeProvider>
</StyleSheetManager> </StyleSheetManager>
<ToastPortal />
</HeroUIProvider>
</QueryClientProvider> </QueryClientProvider>
</Provider> </Provider>
) )

View File

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

View File

@ -1,10 +1,6 @@
@import 'tailwindcss' source('../../../../renderer'); @import 'tailwindcss' source('../../../../renderer');
@import 'tw-animate-css'; @import 'tw-animate-css';
/* heroui */
@plugin '../../hero.ts';
@source '../../../../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
/* 如需自定义: /* 如需自定义:
@ -156,11 +152,6 @@
body { body {
@apply bg-background text-foreground; @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 { :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 React from 'react'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
@ -10,13 +10,10 @@ type Props = {
export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => { export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return ( return (
<Popover> <Popover content={<EmojiPicker onEmojiClick={onPick} />} trigger="click">
<PopoverTrigger> <Button type="text" style={{ width: 32, height: 32, fontSize: 18 }}>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly /> {emoji}
</PopoverTrigger> </Button>
<PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
</PopoverContent>
</Popover> </Popover>
) )
} }

View File

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

View File

@ -1,5 +1,5 @@
import { Button } from '@heroui/react' import { CheckOutlined, CloseOutlined } from '@ant-design/icons'
import { CheckIcon, XIcon } from 'lucide-react' import { Button } from 'antd'
import type { FC } from 'react' import type { FC } from 'react'
import { createPortal } from 'react-dom' 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="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="mr-2 text-sm leading-[1.4]">{message}</div>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger"> <Button
<XIcon className="text-danger-foreground" size={16} /> onClick={onCancel}
</Button> shape="circle"
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success"> size="small"
<CheckIcon className="text-success-foreground" size={16} /> danger
</Button> 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> </div>
</div> </div>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { cn } from '@heroui/react'
import { TopView } from '@renderer/components/TopView' import { TopView } from '@renderer/components/TopView'
import { cn } from '@renderer/utils'
import { Modal } from 'antd' import { Modal } from 'antd'
import { Bot, MessageSquare } from 'lucide-react' import { Bot, MessageSquare } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
@ -51,7 +51,7 @@ const PopupContainer: React.FC<Props> = ({ onSelect, resolve }) => {
<button <button
type="button" type="button"
onClick={() => handleSelect('assistant')} 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')} onMouseEnter={() => setHoveredOption('assistant')}
onMouseLeave={() => setHoveredOption(null)}> onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors"> <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 <button
onClick={() => handleSelect('agent')} onClick={() => handleSelect('agent')}
type="button" 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')} onMouseEnter={() => setHoveredOption('agent')}
onMouseLeave={() => setHoveredOption(null)}> onMouseLeave={() => setHoveredOption(null)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-list-item)] transition-colors"> <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 { loggerService } from '@logger'
import { AppLogo } from '@renderer/config/env' import { AppLogo } from '@renderer/config/env'
import { SettingHelpText, SettingRow } from '@renderer/pages/settings' import { SettingHelpText, SettingRow } from '@renderer/pages/settings'
import type { WebSocketCandidatesResponse } from '@shared/config/types' import type { WebSocketCandidatesResponse } from '@shared/config/types'
import { Alert, Button, Modal, Progress, Spin } from 'antd'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -25,7 +22,7 @@ const LoadingQRCode: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<Spinner /> <Spin />
<span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}> <span style={{ fontSize: '14px', color: 'var(--color-text-2)' }}>
{t('settings.data.export_to_phone.lan.generating_qr')} {t('settings.data.export_to_phone.lan.generating_qr')}
</span> </span>
@ -44,8 +41,8 @@ const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => {
size={200} size={200}
imageSettings={{ imageSettings={{
src: AppLogo, src: AppLogo,
width: 60, width: 40,
height: 60, height: 40,
excavate: true excavate: true
}} }}
/> />
@ -72,7 +69,7 @@ const ConnectingAnimation: React.FC = () => {
borderRadius: '12px', borderRadius: '12px',
backgroundColor: 'var(--color-status-warning)' backgroundColor: 'var(--color-status-warning)'
}}> }}>
<Spinner size="lg" color="warning" /> <Spin size="large" />
<span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}> <span style={{ fontSize: '14px', color: 'var(--color-text)', marginTop: '12px' }}>
{t('settings.data.export_to_phone.lan.status.connecting')} {t('settings.data.export_to_phone.lan.status.connecting')}
</span> </span>
@ -137,7 +134,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null) const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(null)
const [sendProgress, setSendProgress] = useState(0) const [sendProgress, setSendProgress] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null) const [autoCloseCountdown, setAutoCloseCountdown] = useState<number | null>(null)
const { t } = useTranslation() const { t } = useTranslation()
@ -299,22 +295,20 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
// 尝试关闭弹窗 - 如果正在传输则显示确认 // 尝试关闭弹窗 - 如果正在传输则显示确认
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (isSending) { 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 { } else {
setIsOpen(false) setIsOpen(false)
} }
}, [isSending]) }, [isSending, t])
// 确认强制关闭
const handleForceClose = useCallback(() => {
logger.info('Force closing popup during transfer')
setIsOpen(false)
}, [])
// 取消关闭确认
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false)
}, [])
// 清理并关闭 // 清理并关闭
const handleClose = useCallback(async () => { const handleClose = useCallback(async () => {
@ -376,11 +370,13 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
gap: '8px', gap: '8px',
padding: '8px 12px', padding: '5px 12px',
borderRadius: '8px', width: '100%',
backgroundColor: connectionStatusStyles.bg, 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> <span style={{ fontSize: '14px', fontWeight: '500', color: 'var(--color-text)' }}>{connectionStatusText}</span>
</div> </div>
@ -412,7 +408,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
if (!isSending && transferPhase !== 'completed') return null if (!isSending && transferPhase !== 'completed') return null
return ( return (
<div style={{ paddingTop: '8px' }}> <div style={{ paddingTop: '20px' }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -441,11 +437,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</div> </div>
<Progress <Progress
value={Math.round(sendProgress)} percent={Math.round(sendProgress)}
size="md" status={transferPhase === 'completed' ? 'success' : 'active'}
color={transferPhase === 'completed' ? 'success' : 'primary'} showInfo={false}
showValueLabel={false}
aria-label="Send progress"
/> />
</div> </div>
</div> </div>
@ -488,39 +482,32 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return ( return (
<Modal <Modal
isOpen={isOpen} open={isOpen}
onOpenChange={(open) => { onCancel={handleCancel}
if (!open) { afterClose={handleClose}
handleCancel() title={t('settings.data.export_to_phone.lan.title')}
} centered
}} closable={!isSending}
isDismissable={false} maskClosable={false}
isKeyboardDismissDisabled={false} keyboard={true}
placement="center" footer={null}
onClose={handleClose}> styles={{ body: { paddingBottom: 10 } }}>
<ModalContent>
{() => (
<>
<ModalHeader>{t('settings.data.export_to_phone.lan.title')}</ModalHeader>
<ModalBody>
<SettingRow> <SettingRow>
<StatusIndicator /> <StatusIndicator />
</SettingRow> </SettingRow>
<SettingRow> <Alert message={t('settings.data.export_to_phone.lan.content')} type="info" style={{ borderRadius: 0 }} />
<div>{t('settings.data.export_to_phone.lan.content')}</div>
</SettingRow>
<SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px' }}> <SettingRow style={{ display: 'flex', justifyContent: 'center', minHeight: '180px', marginBlock: 25 }}>
<QRCodeDisplay /> <QRCodeDisplay />
</SettingRow> </SettingRow>
<SettingRow style={{ display: 'flex', alignItems: 'center' }}> <SettingRow style={{ display: 'flex', alignItems: 'center', marginBlock: 10 }}>
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}> <div style={{ display: 'flex', gap: 10, justifyContent: 'center', width: '100%' }}>
<Button color="default" variant="flat" onPress={handleSelectZip} isDisabled={isSending}> <Button onClick={handleSelectZip} disabled={isSending}>
{t('settings.data.export_to_phone.lan.selectZip')} {t('settings.data.export_to_phone.lan.selectZip')}
</Button> </Button>
<Button color="primary" onPress={handleSendZip} isDisabled={!canSend} isLoading={isSending}> <Button type="primary" onClick={handleSendZip} disabled={!canSend} loading={isSending}>
{transferStatusText || t('settings.data.export_to_phone.lan.sendZip')} {transferStatusText || t('settings.data.export_to_phone.lan.sendZip')}
</Button> </Button>
</div> </div>
@ -539,44 +526,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
<TransferProgress /> <TransferProgress />
<AutoCloseCountdown /> <AutoCloseCountdown />
<ErrorDisplay /> <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>
</Modal> </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 { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png' 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 { permissionModeCards } from '@renderer/config/agent'
import { agentModelFilter, getModelLogoById } from '@renderer/config/models'
import { useAgents } from '@renderer/hooks/agents/useAgents' import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton'
import type { import type {
AddAgentForm, AddAgentForm,
AgentEntity, AgentEntity,
AgentType, AgentType,
ApiModel,
BaseAgentForm, BaseAgentForm,
PermissionMode, PermissionMode,
Tool, Tool,
UpdateAgentForm UpdateAgentForm
} from '@renderer/types' } from '@renderer/types'
import { AgentConfigurationSchema, isAgentType } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types'
import { Avatar, Button, Input, Modal, Select } from 'antd'
import { AlertTriangleIcon } from 'lucide-react' import { AlertTriangleIcon } from 'lucide-react'
import type { ChangeEvent, FormEvent } from 'react' import type { ChangeEvent, FormEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { ErrorBoundary } from '../../ErrorBoundary' import type { BaseOption } from './shared'
import type { BaseOption, ModelOption } from './shared'
import { Option, renderOption } from './shared' const { TextArea } = Input
const logger = loggerService.withContext('AddAgentPopup') const logger = loggerService.withContext('AddAgentPopup')
@ -48,8 +36,6 @@ interface AgentTypeOption extends BaseOption {
name: AgentEntity['name'] name: AgentEntity['name']
} }
type Option = AgentTypeOption | ModelOption
type AgentWithTools = AgentEntity & { tools?: Tool[] } type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
@ -64,58 +50,37 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {}) configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
}) })
type Props = { interface ShowParams {
agent?: AgentWithTools agent?: AgentWithTools
isOpen: boolean
onClose: () => void
afterSubmit?: (a: AgentEntity) => void afterSubmit?: (a: AgentEntity) => void
} }
/** interface Props extends ShowParams {
* Modal component for creating or editing an agent. resolve: (data: any) => void
* }
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent entity for editing mode. const PopupContainer: React.FC<Props> = ({ agent, afterSubmit, resolve }) => {
* @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 })
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(true)
const loadingRef = useRef(false) const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { addAgent } = useAgents() const { addAgent } = useAgents()
const { updateAgent } = useUpdateAgent() const { updateAgent } = useUpdateAgent()
// hard-coded. We only support anthropic for now.
const { models } = useApiModels({ providerType: 'anthropic' })
const isEditing = (agent?: AgentWithTools) => agent !== undefined const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent)) const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
useEffect(() => { useEffect(() => {
if (isOpen) { if (open) {
setForm(buildAgentForm(agent)) setForm(buildAgentForm(agent))
} }
}, [agent, isOpen]) }, [agent, open])
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const onPermissionModeChange = useCallback((keys: Selection) => { const onPermissionModeChange = useCallback((value: PermissionMode) => {
if (keys === 'all') {
return
}
const [first] = Array.from(keys)
if (!first) {
return
}
setForm((prev) => { setForm((prev) => {
const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {}) const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {})
const nextMode = first as PermissionMode if (parsedConfiguration.permission_mode === value) {
if (parsedConfiguration.permission_mode === nextMode) {
if (!prev.configuration) { if (!prev.configuration) {
return { return {
...prev, ...prev,
@ -129,7 +94,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
...prev, ...prev,
configuration: { configuration: {
...parsedConfiguration, ...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( agentConfig.map((option) => ({
(option) => value: option.key,
({ label: (
...option, <OptionWrapper>
rendered: <Option option={option} /> <Avatar src={option.avatar} size={24} />
}) as const satisfies SelectedItemProps <span>{option.label}</span>
), </OptionWrapper>
)
})),
[agentConfig] [agentConfig]
) )
const onAgentTypeChange = useCallback( const onAgentTypeChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => { (value: AgentType) => {
const prevConfig = agentConfig.find((config) => config.key === form.type) const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === 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) { if (newConfig) {
newName = newConfig.name newName = newConfig.name
} }
} }
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
type: e.target.value as AgentType, type: value,
name: newName name: newName
})) }))
}, },
[agentConfig, form.name, form.type] [agentConfig, form.name, form.type]
) )
const onNameChange = useCallback((name: string) => { const onNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
name name: e.target.value
})) }))
}, []) }, [])
const onDescChange = useCallback((description: string) => { const onDescChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
description description: e.target.value
})) }))
}, []) }, [])
const onInstChange = useCallback((instructions: string) => { const onInstChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
instructions instructions: e.target.value
})) }))
}, []) }, [])
@ -231,34 +198,36 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
})) }))
}, []) }, [])
const modelOptions = useMemo(() => { // Create a temporary agentBase object for SelectAgentBaseModelButton
// mocked data. not final version const tempAgentBase: AgentEntity = useMemo(
return (models ?? []) () => ({
.filter((m) => id: agent?.id ?? 'temp-creating',
agentModelFilter({ type: form.type,
id: m.id, name: form.name,
provider: m.provider || '', model: form.model,
name: m.name, accessible_paths: form.accessible_paths.length > 0 ? form.accessible_paths : ['/'],
group: '' 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]
) )
.map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogoById(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => { const handleModelSelect = useCallback(async (model: ApiModel) => {
setForm((prev) => ({ setForm((prev) => ({ ...prev, model: model.id }))
...prev,
model: e.target.value
}))
}, []) }, [])
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const onSubmit = useCallback( const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => { async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault() e.preventDefault()
@ -330,9 +299,7 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
afterSubmit?.(result.data) afterSubmit?.(result.data)
} }
loadingRef.current = false loadingRef.current = false
setOpen(false)
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
}, },
[ [
form.type, form.type,
@ -344,7 +311,6 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
form.allowed_tools, form.allowed_tools,
form.configuration, form.configuration,
agent, agent,
onClose,
t, t,
updateAgent, updateAgent,
afterSubmit, afterSubmit,
@ -352,138 +318,312 @@ export const AgentModal: React.FC<Props> = ({ agent, isOpen: _isOpen, onClose: _
] ]
) )
AgentModalPopup.hide = onCancel
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Modal <Modal
isOpen={isOpen} title={isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}
onClose={onClose} open={open}
classNames={{ onCancel={onCancel}
base: 'max-h-[90vh]', afterClose={onClose}
wrapper: 'overflow-hidden' transitionName="animation-move-down"
}}> centered
<ModalContent> width={500}
{(onClose) => ( footer={null}>
<> <StyledForm onSubmit={onSubmit}>
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader> <FormContent>
<Form onSubmit={onSubmit} className="min-h-0 w-full shrink overflow-auto"> <FormRow>
<ModalBody className="min-h-0 w-full flex-1 shrink overflow-auto"> <FormItem style={{ flex: 1 }}>
<div className="flex gap-2"> <Label>{t('agent.type.label')}</Label>
<Select <Select
isRequired value={form.type}
isDisabled={isEditing(agent)}
selectionMode="single"
selectedKeys={[form.type]}
disallowEmptySelection
onChange={onAgentTypeChange} onChange={onAgentTypeChange}
items={agentOptions} options={agentOptions}
label={t('agent.type.label')} disabled={isEditing(agent)}
placeholder={t('agent.add.type.placeholder')} style={{ width: '100%' }}
renderValue={renderOption}> />
{(option) => ( </FormItem>
<SelectItem key={option.key} textValue={option.label}> <FormItem style={{ flex: 1 }}>
<Option option={option} /> <Label>
</SelectItem> {t('common.name')} <RequiredMark>*</RequiredMark>
)} </Label>
</Select> <Input value={form.name} onChange={onNameChange} required />
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} /> </FormItem>
</div> </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 <Select
isRequired value={selectedPermissionMode}
selectionMode="single" onChange={onPermissionModeChange}
selectedKeys={form.model ? [form.model] : []} style={{ width: '100%' }}
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')} placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
description={t( dropdownStyle={{ minWidth: '500px' }}
'agent.settings.tooling.permissionMode.helper', optionLabelProp="label">
'Choose how the agent handles tool approvals.' {permissionModeCards.map((item) => (
)} <Select.Option key={item.mode} value={item.mode} label={t(item.titleKey, item.titleFallback)}>
items={permissionModeCards}> <PermissionOptionWrapper>
{(item) => ( <div className="title">{t(item.titleKey, item.titleFallback)}</div>
<SelectItem key={item.mode} textValue={t(item.titleKey, item.titleFallback)}> <div className="description">{t(item.descriptionKey, item.descriptionFallback)}</div>
<div className="flex flex-col gap-1"> <div className="behavior">{t(item.behaviorKey, item.behaviorFallback)}</div>
<span className="font-medium text-sm">{t(item.titleKey, item.titleFallback)}</span> {item.caution && (
<span className="text-foreground-500 text-xs"> <div className="caution">
{t(item.descriptionKey, item.descriptionFallback)} <AlertTriangleIcon size={12} />
</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( {t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning', 'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.' 'Use with caution — all tools will run without asking for approval.'
)} )}
</span>
) : null}
</div> </div>
</SelectItem>
)} )}
</PermissionOptionWrapper>
</Select.Option>
))}
</Select> </Select>
<div className="space-y-2"> <HelpText>
<div className="flex items-center justify-between"> {t('agent.settings.tooling.permissionMode.helper', 'Choose how the agent handles tool approvals.')}
<span className="font-medium text-foreground text-sm"> </HelpText>
{t('agent.session.accessible_paths.label')} </FormItem>
</span>
<Button size="sm" variant="flat" onPress={addAccessiblePath}> <FormItem>
<LabelWithButton>
<Label>
{t('agent.session.accessible_paths.label')} <RequiredMark>*</RequiredMark>
</Label>
<Button size="small" onClick={addAccessiblePath}>
{t('agent.session.accessible_paths.add')} {t('agent.session.accessible_paths.add')}
</Button> </Button>
</div> </LabelWithButton>
{form.accessible_paths.length > 0 ? ( {form.accessible_paths.length > 0 ? (
<div className="space-y-2"> <PathList>
{form.accessible_paths.map((path) => ( {form.accessible_paths.map((path) => (
<div <PathItem key={path}>
key={path} <PathText title={path}>{path}</PathText>
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2"> <Button size="small" danger onClick={() => removeAccessiblePath(path)}>
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</div> </PathItem>
))} ))}
</div> </PathList>
) : ( ) : (
<p className="text-foreground-400 text-sm">{t('agent.session.accessible_paths.empty')}</p> <EmptyText>{t('agent.session.accessible_paths.empty')}</EmptyText>
)} )}
</div> </FormItem>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
<Textarea <FormItem>
label={t('common.description')} <Label>{t('common.prompt')}</Label>
value={form.description ?? ''} <TextArea rows={3} value={form.instructions ?? ''} onChange={onInstChange} />
onValueChange={onDescChange} </FormItem>
/>
</ModalBody> <FormItem>
<ModalFooter className="w-full"> <Label>{t('common.description')}</Label>
<Button onPress={onClose}>{t('common.close')}</Button> <TextArea rows={2} value={form.description ?? ''} onChange={onDescChange} />
<Button color="primary" type="submit" isLoading={loadingRef.current}> </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')} {isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button> </Button>
</ModalFooter> </FormFooter>
</Form> </StyledForm>
</>
)}
</ModalContent>
</Modal> </Modal>
</ErrorBoundary> </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 { export interface BaseOption {
type: 'type' | 'model' type: 'type' | 'model'
key: string key: string
@ -10,43 +5,3 @@ export interface BaseOption {
// img src // img src
avatar?: string 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 TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit' import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts' import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { Modal } from 'antd' import { message, Modal } from 'antd'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box } from '../Layout' import { Box } from '../Layout'
import { getToastUtilities } from './toast' import { getToastUtilities, initMessageApi } from './toast'
let onPop = () => {} let onPop = () => {}
let onShow = ({ element, id }: { element: React.FC | React.ReactNode; id: string }) => { let onShow = ({ element, id }: { element: React.FC | React.ReactNode; id: string }) => {
@ -36,6 +36,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
elementsRef.current = elements elementsRef.current = elements
const [modal, modalContextHolder] = Modal.useModal() const [modal, modalContextHolder] = Modal.useModal()
const [messageApi, messageContextHolder] = message.useMessage()
const { shortcuts } = useShortcuts() const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
@ -43,8 +44,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
useEffect(() => { useEffect(() => {
window.modal = modal window.modal = modal
initMessageApi(messageApi)
window.toast = getToastUtilities() window.toast = getToastUtilities()
}, [modal]) }, [messageApi, modal])
onPop = () => { onPop = () => {
const views = [...elementsRef.current] const views = [...elementsRef.current]
@ -97,6 +99,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
return ( return (
<> <>
{children} {children}
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<TopViewMinappContainer /> <TopViewMinappContainer />
{elements.map(({ element: Element, id }) => ( {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" /> /// <reference types="vite/client" />
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' 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 KeyvStorage from '@kangfenmao/keyv-storage'
import type { HookAPI } from 'antd/es/modal/useModal' import type { HookAPI } from 'antd/es/modal/useModal'
import type { NavigateFunction } from 'react-router-dom' 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 { interface ImportMetaEnv {
VITE_RENDERER_INTEGRATED_MODEL: string 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. // NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data return result.data
}, [apiServerConfig.enabled, apiServerRunning, client, t]) }, [apiServerConfig.enabled, apiServerRunning, client, t])
const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher) const { data, error, isLoading, mutate } = useSWR(swrKey, fetcher)
const { chat } = useRuntime() const { chat } = useRuntime()
const { activeAgentId } = chat const { activeAgentId } = chat

View File

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

View File

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

View File

@ -12,19 +12,7 @@ export default function useUserTheme() {
const colorPrimary = Color(theme.colorPrimary) const colorPrimary = Color(theme.colorPrimary)
document.body.style.setProperty('--color-primary', colorPrimary.toString()) document.body.style.setProperty('--color-primary', colorPrimary.toString())
// overwrite hero UI primary color.
document.body.style.setProperty('--primary', colorPrimary.toString()) 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-soft', colorPrimary.alpha(0.6).toString())
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString()) document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
@ -15,7 +14,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { estimateMessageUsage } from '@renderer/services/TokenService' import { estimateMessageUsage } from '@renderer/services/TokenService'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage' 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 { isMessageProcessing } from '@renderer/utils/messageUtils/is'
import { Divider } from 'antd' import { Divider } from 'antd'
import type { Dispatch, FC, SetStateAction } from 'react' 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 { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -15,7 +16,13 @@ interface ParsedBashOutput {
tool_use_error?: string 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 输出 // 解析 XML 输出
const parsedOutput = useMemo(() => { const parsedOutput = useMemo(() => {
if (!output) return null if (!output) return null
@ -84,49 +91,15 @@ export function BashOutputTool({ input, output }: { input: BashOutputToolInput;
} as const } as const
}, [parsedOutput]) }, [parsedOutput])
return ( const children = parsedOutput ? (
<AccordionItem <div className="flex flex-col gap-4">
key={AgentToolsType.BashOutput}
aria-label="BashOutput Tool"
title={
<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>
{statusConfig && (
<Chip
size="sm"
color={statusConfig.color}
variant="flat"
startContent={statusConfig.icon}
className="h-5">
{statusConfig.text}
</Chip>
)}
</div>
}
/>
}
classNames={{
content: 'space-y-3 px-1'
}}>
{parsedOutput ? (
<>
{/* Status Info */} {/* Status Info */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && ( {parsedOutput.exit_code !== undefined && (
<Chip size="sm" color={parsedOutput.exit_code === 0 ? 'success' : 'danger'} variant="flat"> <Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag>
Exit Code: {parsedOutput.exit_code}
</Chip>
)} )}
{parsedOutput.timestamp && ( {parsedOutput.timestamp && (
<Code size="sm" className="py-0 text-xs"> <Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag>
{new Date(parsedOutput.timestamp).toLocaleString()}
</Code>
)} )}
</div> </div>
@ -162,7 +135,7 @@ export function BashOutputTool({ input, output }: { input: BashOutputToolInput;
</pre> </pre>
</div> </div>
)} )}
</> </div>
) : ( ) : (
// 原始输出(如果解析失败或非 XML 格式) // 原始输出(如果解析失败或非 XML 格式)
output && ( output && (
@ -170,7 +143,36 @@ export function BashOutputTool({ input, output }: { input: BashOutputToolInput;
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre> <pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
</div> </div>
) )
)}
</AccordionItem>
) )
return {
key: AgentToolsType.BashOutput,
label: (
<>
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash Output"
params={
<div className="flex items-center gap-2">
<Tag className="py-0 font-mono text-xs">{input.bash_id}</Tag>
{statusConfig && (
<Tag
color={statusConfig.color}
icon={statusConfig.icon}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{statusConfig.text}
</Tag>
)}
</div>
}
/>
</>
),
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 { Terminal } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types' 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 const outputLines = output ? output.split('\n').length : 0
return ( return {
<AccordionItem key: 'tool',
key="tool" label: (
aria-label="Bash Tool" <>
title={
<ToolTitle <ToolTitle
icon={<Terminal className="h-4 w-4" />} icon={<Terminal className="h-4 w-4" />}
label="Bash" label="Bash"
params={input.description} params={input.description}
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
/> />
<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>
} }
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>
)
} }

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { FileEdit } from 'lucide-react' import { FileEdit } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
@ -28,12 +28,18 @@ export const renderCodeBlock = (content: string, variant: 'old' | 'new') => {
) )
} }
export function EditTool({ input, output }: { input: EditToolInput; output?: EditToolOutput }) { export function EditTool({
return ( input,
<AccordionItem output
key={AgentToolsType.Edit} }: {
aria-label="Edit Tool" input: EditToolInput
title={<ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input.file_path} />}> 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 */} {/* Diff View */}
{/* Old Content */} {/* Old Content */}
{renderCodeBlock(input.old_string, 'old')} {renderCodeBlock(input.old_string, 'old')}
@ -41,6 +47,7 @@ export function EditTool({ input, output }: { input: EditToolInput; output?: Edi
{renderCodeBlock(input.new_string, 'new')} {renderCodeBlock(input.new_string, 'new')}
{/* Output */} {/* Output */}
{output} {output}
</AccordionItem> </>
) )
} }
}

View File

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

View File

@ -1,26 +1,29 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { FolderSearch } from 'lucide-react' import { FolderSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types' 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 const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return ( return {
<AccordionItem key: 'tool',
key="tool" label: (
aria-label="Glob Tool"
title={
<ToolTitle <ToolTitle
icon={<FolderSearch className="h-4 w-4" />} icon={<FolderSearch className="h-4 w-4" />}
label="Glob" label="Glob"
params={input.pattern} params={input.pattern}
stats={output ? `${lineCount} of output` : undefined} stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined}
/> />
}> ),
<div>{output}</div> children: <div>{output}</div>
</AccordionItem> }
)
} }

View File

@ -1,18 +1,22 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { FileSearch } from 'lucide-react' import { FileSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { GrepToolInput, GrepToolOutput } from './types' 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 const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0
return ( return {
<AccordionItem key: 'tool',
key="tool" label: (
aria-label="Grep Tool"
title={
<ToolTitle <ToolTitle
icon={<FileSearch className="h-4 w-4" />} icon={<FileSearch className="h-4 w-4" />}
label="Grep" label="Grep"
@ -24,8 +28,7 @@ export function GrepTool({ input, output }: { input: GrepToolInput; output?: Gre
} }
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined} stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
/> />
}> ),
<div>{output}</div> children: <div>{output}</div>
</AccordionItem> }
)
} }

View File

@ -1,4 +1,4 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react' import { FileText } from 'lucide-react'
import { renderCodeBlock } from './EditTool' import { renderCodeBlock } from './EditTool'
@ -6,18 +6,24 @@ import { ToolTitle } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types' import type { MultiEditToolInput, MultiEditToolOutput } from './types'
import { AgentToolsType } from './types' import { AgentToolsType } from './types'
export function MultiEditTool({ input }: { input: MultiEditToolInput; output?: MultiEditToolOutput }) { export function MultiEditTool({
return ( input
<AccordionItem }: {
key={AgentToolsType.MultiEdit} input: MultiEditToolInput
aria-label="MultiEdit Tool" output?: MultiEditToolOutput
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input.file_path} />}> }): 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) => ( {input.edits.map((edit, index) => (
<div key={index}> <div key={index}>
{renderCodeBlock(edit.old_string, 'old')} {renderCodeBlock(edit.old_string, 'old')}
{renderCodeBlock(edit.new_string, 'new')} {renderCodeBlock(edit.new_string, 'new')}
</div> </div>
))} ))}
</AccordionItem> </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 { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
@ -6,14 +7,23 @@ import { ToolTitle } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types' import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types' import { AgentToolsType } from './types'
export function NotebookEditTool({ input, output }: { input: NotebookEditToolInput; output?: NotebookEditToolOutput }) { export function NotebookEditTool({
return ( input,
<AccordionItem output
key={AgentToolsType.NotebookEdit} }: {
aria-label="NotebookEdit Tool" input: NotebookEditToolInput
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />} output?: NotebookEditToolOutput
subtitle={input.notebook_path}> }): NonNullable<CollapseProps['items']>[number] {
<ReactMarkdown>{output}</ReactMarkdown> return {
</AccordionItem> 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 { FileText } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown' 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 type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } 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 标签及其内容的辅助函数 // 移除 system-reminder 标签及其内容的辅助函数
const removeSystemReminderTags = (text: string): string => { const removeSystemReminderTags = (text: string): string => {
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符 // 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
@ -53,19 +59,16 @@ export function ReadTool({ input, output }: { input: ReadToolInputType; output?:
} }
}, [outputString]) }, [outputString])
return ( return {
<AccordionItem key: AgentToolsType.Read,
key={AgentToolsType.Read} label: (
aria-label="Read Tool"
title={
<ToolTitle <ToolTitle
icon={<FileText className="h-4 w-4" />} icon={<FileText className="h-4 w-4" />}
label="Read File" label="Read File"
params={input.file_path.split('/').pop()} params={input.file_path.split('/').pop()}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined} stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
/> />
}> ),
{outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null} children: outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null
</AccordionItem> }
)
} }

View File

@ -1,25 +1,30 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools' import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools'
import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types' 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 const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return ( return {
<AccordionItem key: 'tool',
key="tool" label: (
aria-label="Search Tool"
title={
<ToolTitle <ToolTitle
icon={<Search className="h-4 w-4" />} icon={<Search className="h-4 w-4" />}
label="Search" label="Search"
params={`"${input}"`} params={`"${input}"`}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/> />
}> ),
children: (
<div> <div>
<StringInputTool input={input} label="Search Query" /> <StringInputTool input={input} label="Search Query" />
{output && ( {output && (
@ -28,6 +33,6 @@ export function SearchTool({ input, output }: { input: SearchToolInputType; outp
</div> </div>
)} )}
</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 { PencilRuler } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { SkillToolInput, SkillToolOutput } from './types' import type { SkillToolInput, SkillToolOutput } from './types'
export function SkillTool({ input, output }: { input: SkillToolInput; output?: SkillToolOutput }) { export function SkillTool({
return ( input,
<AccordionItem output
key="tool" }: {
aria-label="Skill Tool" input: SkillToolInput
title={<ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input.command} />}> output?: SkillToolOutput
{output} }): NonNullable<CollapseProps['items']>[number] {
</AccordionItem> 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 { Bot } from 'lucide-react'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types' import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types'
export function TaskTool({ input, output }: { input: TaskToolInputType; output?: TaskToolOutputType }) { export function TaskTool({
return ( input,
<AccordionItem output
key="tool" }: {
aria-label="Task Tool" input: TaskToolInputType
title={<ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input.description} />}> 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) => ( {output?.map((item) => (
<div key={item.type}> <div key={item.type}>
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div> <div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
</div> </div>
))} ))}
</AccordionItem> </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 { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
@ -30,30 +32,44 @@ 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 const doneCount = input.todos.filter((todo) => todo.status === 'completed').length
return (
<AccordionItem return {
key={AgentToolsType.TodoWrite} key: AgentToolsType.TodoWrite,
aria-label="Todo Write Tool" label: (
title={
<ToolTitle <ToolTitle
icon={<ListTodo className="h-4 w-4" />} icon={<ListTodo className="h-4 w-4" />}
label="Todo Write" label="Todo Write"
params={`${doneCount} Done`} params={`${doneCount} Done`}
stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`} stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`}
/> />
}> ),
children: (
<div className="space-y-3"> <div className="space-y-3">
{input.todos.map((todo, index) => { {input.todos.map((todo, index) => {
const statusConfig = getStatusConfig(todo.status) const statusConfig = getStatusConfig(todo.status)
return ( return (
<Card key={index} className="shadow-sm"> <div key={index}>
<CardBody className="p-2"> <Card
<div className="flex items-start gap-3"> key={index}
<Chip color={statusConfig.color} variant="flat" size="sm" className="flex-shrink-0"> 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} {statusConfig.icon}
</Chip> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}> <div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}>
{todo.status === 'completed' ? <s>{todo.content}</s> : todo.content} {todo.status === 'completed' ? <s>{todo.content}</s> : todo.content}
@ -63,11 +79,12 @@ export function TodoWriteTool({ input }: { input: TodoWriteToolInputType }) {
)} )}
</div> </div>
</div> </div>
</CardBody> </div>
</Card> </Card>
</div>
) )
})} })}
</div> </div>
</AccordionItem>
) )
} }
}

View File

@ -1,5 +1,5 @@
import { AccordionItem } from '@heroui/react'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import type { CollapseProps } from 'antd'
import { Wrench } from 'lucide-react' import { Wrench } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -11,7 +11,11 @@ interface UnknownToolProps {
output?: unknown output?: unknown
} }
export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToolProps) { export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const { highlightCode } = useCodeStyle() const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('') const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('') const [outputHtml, setOutputHtml] = useState<string>('')
@ -47,17 +51,16 @@ export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToo
return 'Tool' return 'Tool'
} }
return ( return {
<AccordionItem key: 'unknown-tool',
key="unknown-tool" label: (
aria-label={toolName}
title={
<ToolTitle <ToolTitle
icon={<Wrench className="h-4 w-4" />} icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)} label={getToolDisplayName(toolName)}
params={getToolDescription()} params={getToolDescription()}
/> />
}> ),
children: (
<div className="space-y-3"> <div className="space-y-3">
{input !== undefined && ( {input !== undefined && (
<div> <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 className="text-foreground-500 text-xs">No data available for this tool</div>
)} )}
</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 { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { WebFetchToolInput, WebFetchToolOutput } from './types' import type { WebFetchToolInput, WebFetchToolOutput } from './types'
export function WebFetchTool({ input, output }: { input: WebFetchToolInput; output?: WebFetchToolOutput }) { export function WebFetchTool({
return ( input,
<AccordionItem output
key="tool" }: {
aria-label="Web Fetch Tool" input: WebFetchToolInput
title={<ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input.url} />} output?: WebFetchToolOutput
subtitle={input.prompt}> }): NonNullable<CollapseProps['items']>[number] {
{output} return {
</AccordionItem> 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 { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { WebSearchToolInput, WebSearchToolOutput } from './types' 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 const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return ( return {
<AccordionItem key: 'tool',
key="tool" label: (
aria-label="Web Search Tool"
title={
<ToolTitle <ToolTitle
icon={<Globe className="h-4 w-4" />} icon={<Globe className="h-4 w-4" />}
label="Web Search" label="Web Search"
params={input.query} params={input.query}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/> />
}> ),
{output} children: <div>{output}</div>
</AccordionItem> }
)
} }

View File

@ -1,16 +1,18 @@
import { AccordionItem } from '@heroui/react' import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react' import { FileText } from 'lucide-react'
import { ToolTitle } from './GenericTools' import { ToolTitle } from './GenericTools'
import type { WriteToolInput, WriteToolOutput } from './types' import type { WriteToolInput, WriteToolOutput } from './types'
export function WriteTool({ input }: { input: WriteToolInput; output?: WriteToolOutput }) { export function WriteTool({
return ( input
<AccordionItem }: {
key="tool" input: WriteToolInput
aria-label="Write Tool" output?: WriteToolOutput
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input.file_path} />}> }): NonNullable<CollapseProps['items']>[number] {
<div>{input.content}</div> return {
</AccordionItem> 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 { loggerService } from '@logger'
import type { NormalToolResponse } from '@renderer/types' import type { NormalToolResponse } from '@renderer/types'
import type { CollapseProps } from 'antd'
import { Collapse } from 'antd'
// 导出所有类型 // 导出所有类型
export * from './types' export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器 // 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard' import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool' import { BashOutputTool } from './BashOutputTool'
@ -58,25 +61,27 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) { function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
const Renderer = toolRenderers[toolName] const Renderer = toolRenderers[toolName]
return ( // eslint-disable-next-line react-hooks/rules-of-hooks
<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"> const toolContentItem = useMemo(() => {
<Accordion const rendered = Renderer
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 }) ? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })} : UnknownToolRenderer({ input: input as any, output: output as any, toolName })
</Accordion> return {
</div> ...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 (
<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 type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { Button, Chip, ScrollShadow } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions' import { selectPendingPermissionByToolName, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types' import type { NormalToolResponse } from '@renderer/types'
import { Button } from 'antd'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react' import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -127,33 +127,39 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2"> <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 {isExpired
? t('agent.toolPermission.expired') ? t('agent.toolPermission.expired')
: t('agent.toolPermission.pending', { seconds: remainingSeconds })} : t('agent.toolPermission.pending', { seconds: remainingSeconds })}
</Chip> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
aria-label={t('agent.toolPermission.aria.denyRequest')} aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8" className="h-8"
color="danger" color="danger"
isDisabled={isSubmitting || isExpired} disabled={isSubmitting || isExpired}
isLoading={isSubmittingDeny} loading={isSubmittingDeny}
onPress={() => handleDecision('deny')} onClick={() => handleDecision('deny')}
startContent={<CircleX size={16} />} icon={<CircleX size={16} />}
variant="bordered"> iconPosition={'start'}
variant="outlined">
{t('agent.toolPermission.button.cancel')} {t('agent.toolPermission.button.cancel')}
</Button> </Button>
<Button <Button
aria-label={t('agent.toolPermission.aria.allowRequest')} aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3" className="h-8 px-3"
color="success" color="primary"
isDisabled={isSubmitting || isExpired} disabled={isSubmitting || isExpired}
isLoading={isSubmittingAllow} loading={isSubmittingAllow}
onPress={() => handleDecision('allow')} onClick={() => handleDecision('allow')}
startContent={<CirclePlay size={16} />}> icon={<CirclePlay size={16} />}
iconPosition={'start'}
variant="solid">
{t('agent.toolPermission.button.run')} {t('agent.toolPermission.button.run')}
</Button> </Button>
@ -161,12 +167,12 @@ export function ToolPermissionRequestCard({ toolResponse }: Props) {
aria-label={ aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails') showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
} }
className="h-8" className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
isIconOnly onClick={() => setShowDetails((value) => !value)}
onPress={() => setShowDetails((value) => !value)} icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
variant="light"> variant="text"
<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} /> style={{ backgroundColor: 'transparent' }}
</Button> />
</div> </div>
</div> </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"> <p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')} {t('agent.toolPermission.inputPreview')}
</p> </p>
<ScrollShadow className="max-h-48 font-mono text-xs" hideScrollBar> <div className="max-h-[192px] overflow-auto font-mono text-xs">
<pre className="whitespace-pre-wrap break-all text-left">{request.inputPreview}</pre> <pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
</ScrollShadow> </div>
</div> </div>
{request.requiresPermissions && ( {request.requiresPermissions && (

View File

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

View File

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

View File

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

View File

@ -1,18 +1,32 @@
import type { ButtonProps } from '@heroui/react' import type { ButtonProps } from 'antd'
import { Button, cn } from '@heroui/react' import { Button } from 'antd'
import { PlusIcon } from 'lucide-react' 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 ( return (
<Button <StyledButton
className={cn( {...props}
'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)]', type="text"
className onClick={props.onClick}
)} icon={<PlusIcon size={16} style={{ flexShrink: 0 }} />}>
startContent={<PlusIcon size={16} className="shrink-0" />} {props.children}
{...props}> </StyledButton>
{children}
</Button>
) )
} }

View File

@ -1,4 +1,3 @@
import { cn, Tooltip } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions' import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useSettings } from '@renderer/hooks/useSettings' 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 { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { AgentEntity } from '@renderer/types' 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 { Bot } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
import { memo, useCallback } from 'react' import { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem') // const logger = loggerService.withContext('AgentItem')
@ -36,9 +37,38 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
onPress() onPress()
}, [clickAssistantToShowTopic, topicPosition, 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 ( return (
<ContextMenu modal={false}> <Dropdown
<ContextMenuTrigger> menu={{ items: menuItems }}
trigger={['contextMenu']}
popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
<Container onClick={handlePress} isActive={isActive}> <Container onClick={handlePress} isActive={isActive}>
<AssistantNameRow className="name" title={agent.name ?? agent.id}> <AssistantNameRow className="name" title={agent.name ?? agent.id}>
<AgentNameWrapper> <AgentNameWrapper>
@ -52,29 +82,7 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
{!isActive && <BotIcon />} {!isActive && <BotIcon />}
</AssistantNameRow> </AssistantNameRow>
</Container> </Container>
</ContextMenuTrigger> </Dropdown>
<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>
) )
} }
@ -118,7 +126,7 @@ export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ cla
export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...props }) => { export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...props }) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Tooltip content={t('common.agent_one')} delay={500} closeDelay={0}> <Tooltip title={t('common.agent_one')} mouseEnterDelay={0.5}>
<MenuButton {...props}> <MenuButton {...props}>
<Bot size={14} className="text-primary" /> <Bot size={14} className="text-primary" />
</MenuButton> </MenuButton>

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import EmojiIcon from '@renderer/components/EmojiIcon' import EmojiIcon from '@renderer/components/EmojiIcon'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' 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 { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime' import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, AssistantsSortType } from '@renderer/types' 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 { hasTopicPendingRequests } from '@renderer/utils/queue'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import { Dropdown } 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 { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
@ -8,26 +7,19 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings' import { SessionSettingsPopup } from '@renderer/pages/settings/AgentSettings'
import { SessionLabel } from '@renderer/pages/settings/AgentSettings/shared' 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 { newMessagesActions } from '@renderer/store/newMessage'
import type { AgentSessionEntity } from '@renderer/types' import { loadTopicMessagesThunk, renameAgentSessionIfNeeded } from '@renderer/store/thunk/messageThunk'
import { import type { AgentSessionEntity, Assistant } from '@renderer/types'
ContextMenu, import { classNames } from '@renderer/utils'
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger
} from '@renderer/ui/context-menu'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Tooltip } from 'antd' import type { MenuProps } from 'antd'
import { MenuIcon, XIcon } from 'lucide-react' import { Dropdown, Tooltip } from 'antd'
import { MenuIcon, Sparkles, XIcon } from 'lucide-react'
import type { FC } from '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 { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { ListItem, ListItemEditInput, ListItemName, ListItemNameContainer, MenuButton, StatusIndicator } from './shared'
// const logger = loggerService.withContext('AgentItem') // const logger = loggerService.withContext('AgentItem')
@ -46,6 +38,8 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const activeSessionId = chat.activeSessionIdMap[agentId] const activeSessionId = chat.activeSessionIdMap[agentId]
const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false) const [isConfirmingDeletion, setIsConfirmingDeletion] = useState(false)
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
const [_targetSession, setTargetSession] = useState<AgentSessionEntity>(session)
const targetSession = useDeferredValue(_targetSession)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({ const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
@ -68,6 +62,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
</div> </div>
}> }>
<MenuButton <MenuButton
className="menu"
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (isConfirmingDeletion || e.ctrlKey || e.metaKey) { if (isConfirmingDeletion || e.ctrlKey || e.metaKey) {
@ -111,25 +106,80 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const { topicPosition, setTopicPosition } = useSettings() const { topicPosition, setTopicPosition } = useSettings()
const singlealone = topicPosition === 'right' 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 ( return (
<> <Dropdown
<ContextMenu modal={false}> menu={{ items: menuItems }}
<ContextMenuTrigger> trigger={['contextMenu']}
<ListItem popupRender={(menu) => <div onPointerDown={(e) => e.stopPropagation()}>{menu}</div>}>
className={cn( <SessionListItem
isActive ? 'active' : undefined, className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
singlealone ? 'singlealone' : undefined,
isEditing ? 'cursor-default' : 'cursor-pointer',
'rounded-[var(--list-item-border-radius)]'
)}
onClick={isEditing ? undefined : onPress} onClick={isEditing ? undefined : onPress}
onDoubleClick={() => startEdit(session.name ?? '')} onDoubleClick={() => startEdit(session.name ?? '')}
title={session.name ?? session.id}> title={session.name ?? session.id}
{isPending && !isActive && <StatusIndicator variant="pending" />} onContextMenu={() => setTargetSession(session)}
{isFulfilled && !isActive && <StatusIndicator variant="fulfilled" />} style={{
<ListItemNameContainer> borderRadius: 'var(--list-item-border-radius)',
cursor: isEditing ? 'default' : 'pointer'
}}>
{isPending && !isActive && <PendingIndicator />}
{isFulfilled && !isActive && <FulfilledIndicator />}
<SessionNameContainer>
{isEditing ? ( {isEditing ? (
<ListItemEditInput <SessionEditInput
ref={inputRef} ref={inputRef}
value={editValue} value={editValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
@ -139,54 +189,133 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
/> />
) : ( ) : (
<> <>
<ListItemName> <SessionName>
<SessionLabel session={session} /> <SessionLabel session={session} />
</ListItemName> </SessionName>
<DeleteButton /> <DeleteButton />
</> </>
)} )}
</ListItemNameContainer> </SessionNameContainer>
</ListItem> </SessionListItem>
</ContextMenuTrigger> </Dropdown>
<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>
</>
) )
} }
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) 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 { DynamicVirtualList } from '@renderer/components/VirtualList'
import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession' import { useCreateDefaultSession } from '@renderer/hooks/agents/useCreateDefaultSession'
import { useSessions } from '@renderer/hooks/agents/useSessions' import { useSessions } from '@renderer/hooks/agents/useSessions'
@ -11,13 +11,14 @@ import {
setSessionWaitingAction setSessionWaitingAction
} from '@renderer/store/runtime' } from '@renderer/store/runtime'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { Alert, Spin } from 'antd'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { memo, useCallback, useEffect } from 'react' import { memo, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddButton from './AddButton' import AddButton from './AddButton'
import SessionItem from './SessionItem' import SessionItem from './SessionItem'
import { ListContainer } from './shared'
// const logger = loggerService.withContext('SessionsTab') // const logger = loggerService.withContext('SessionsTab')
@ -88,16 +89,18 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="flex h-full items-center justify-center"> className="flex h-full items-center justify-center">
<Spinner size="lg" /> <Spin />
</motion.div> </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 ( return (
<ListContainer className="sessions-tab"> <Container className="sessions-tab">
<AddButton onPress={createDefaultSession} className="mb-2" isDisabled={creatingSession}> <AddButton onClick={createDefaultSession} disabled={creatingSession} className="-mt-[4px] mb-[6px]">
{t('agent.session.add.title')} {t('agent.session.add.title')}
</AddButton> </AddButton>
{/* h-9 */} {/* h-9 */}
@ -119,8 +122,15 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
/> />
)} )}
</DynamicVirtualList> </DynamicVirtualList>
</ListContainer> </Container>
) )
} }
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
padding: 12px 10px;
overflow-x: hidden;
`
export default memo(Sessions) export default memo(Sessions)

View File

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

View File

@ -1,4 +1,3 @@
import { cn } from '@heroui/react'
import { DraggableVirtualList } from '@renderer/components/DraggableList' import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
@ -18,7 +17,7 @@ import store from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage' import { newMessagesActions } from '@renderer/store/newMessage'
import { setGenerating } from '@renderer/store/runtime' import { setGenerating } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types' 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 { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
import { import {
exportMarkdownToJoplin, exportMarkdownToJoplin,
@ -54,15 +53,6 @@ import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
import AddButton from './AddButton' import AddButton from './AddButton'
import {
ListContainer,
ListItem,
ListItemEditInput,
ListItemName,
ListItemNameContainer,
MenuButton,
StatusIndicator
} from './shared'
interface Props { interface Props {
assistant: Assistant 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 topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
const newlyRenamedTopics = useSelector((state: RootState) => state.runtime.chat.newlyRenamedTopics) 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 [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
const deleteTimerRef = useRef<NodeJS.Timeout>(null) const deleteTimerRef = useRef<NodeJS.Timeout>(null)
const [editingTopicId, setEditingTopicId] = useState<string | null>(null) const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
@ -497,11 +489,20 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
const singlealone = topicPosition === 'right' && position === 'right' const singlealone = topicPosition === 'right' && position === 'right'
return ( return (
<ListContainer className="topics-tab"> <DraggableVirtualList
<AddButton onPress={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)} className="mb-2"> 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')} {t('chat.add.topic.title')}
</AddButton> </AddButton>
<DraggableVirtualList list={sortedTopics} onUpdate={updateTopics} className="overflow-y-auto overflow-x-hidden"> <div className="my-1"></div>
</>
}>
{(topic) => { {(topic) => {
const isActive = topic.id === activeTopic?.id const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '') const topicName = topic.name.replace('`', '')
@ -516,20 +517,19 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
return ( return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}> <Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<ListItem <TopicListItem
onContextMenu={() => setTargetTopic(topic)} onContextMenu={() => setTargetTopic(topic)}
className={cn( className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
isActive ? 'active' : undefined, onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
singlealone ? 'singlealone' : undefined, style={{
editingTopicId === topic.id && topicEdit.isEditing ? 'cursor-default' : 'cursor-pointer', borderRadius,
showTopicTime ? 'rounded-2xl' : 'rounded-[var(--list-item-border-radius)]' cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
)} }}>
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}> {isPending(topic.id) && !isActive && <PendingIndicator />}
{isPending(topic.id) && !isActive && <StatusIndicator variant="pending" />} {isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
{isFulfilled(topic.id) && !isActive && <StatusIndicator variant="fulfilled" />} <TopicNameContainer>
<ListItemNameContainer>
{editingTopicId === topic.id && topicEdit.isEditing ? ( {editingTopicId === topic.id && topicEdit.isEditing ? (
<ListItemEditInput <TopicEditInput
ref={topicEdit.inputRef} ref={topicEdit.inputRef}
value={topicEdit.editValue} value={topicEdit.editValue}
onChange={topicEdit.handleInputChange} onChange={topicEdit.handleInputChange}
@ -537,7 +537,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<ListItemName <TopicName
className={getTopicNameClassName()} className={getTopicNameClassName()}
title={topicName} title={topicName}
onDoubleClick={() => { onDoubleClick={() => {
@ -545,7 +545,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
topicEdit.startEdit(topic.name) topicEdit.startEdit(topic.name)
}}> }}>
{topicName} {topicName}
</ListItemName> </TopicName>
)} )}
{!topic.pinned && ( {!topic.pinned && (
<Tooltip <Tooltip
@ -558,6 +558,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
</div> </div>
}> }>
<MenuButton <MenuButton
className="menu"
onClick={(e) => { onClick={(e) => {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e) handleConfirmDelete(topic, e)
@ -580,24 +581,163 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
<PinIcon size={14} color="var(--color-text-3)" /> <PinIcon size={14} color="var(--color-text-3)" />
</MenuButton> </MenuButton>
)} )}
</ListItemNameContainer> </TopicNameContainer>
{topicPrompt && ( {topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}> <TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt} {fullTopicPrompt}
</TopicPromptText> </TopicPromptText>
)} )}
{showTopicTime && ( {showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
<TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime> </TopicListItem>
)}
</ListItem>
</Dropdown> </Dropdown>
) )
}} }}
</DraggableVirtualList> </DraggableVirtualList>
</ListContainer>
) )
} }
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` const TopicPromptText = styled.div`
color: var(--color-text-2); color: var(--color-text-2);
font-size: 12px; font-size: 12px;
@ -614,3 +754,15 @@ const TopicTime = styled.div`
color: var(--color-text-3); color: var(--color-text-3);
font-size: 11px; 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 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 { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime' import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { AgentEntity, Assistant, Topic } from '@renderer/types' import type { AgentEntity, Assistant, Topic } from '@renderer/types'
@ -18,20 +18,8 @@ interface UnifiedAddButtonProps {
const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setActiveAssistant, setActiveAgentId }) => { const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setActiveAssistant, setActiveAgentId }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { isOpen: isAgentModalOpen, onOpen: onAgentModalOpen, onClose: onAgentModalClose } = useDisclosure()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { apiServerRunning, startApiServer } = useApiServer()
const handleAddButtonClick = () => {
AddAssistantOrAgentPopup.show({
onSelect: (type) => {
if (type === 'assistant') {
onCreateAssistant()
} else if (type === 'agent') {
onAgentModalOpen()
}
}
})
}
const afterCreate = useCallback( const afterCreate = useCallback(
(a: AgentEntity) => { (a: AgentEntity) => {
@ -58,12 +46,24 @@ const UnifiedAddButton: FC<UnifiedAddButtonProps> = ({ onCreateAssistant, setAct
[dispatch, setActiveAgentId, setActiveAssistant] [dispatch, setActiveAgentId, setActiveAssistant]
) )
const handleAddButtonClick = async () => {
AddAssistantOrAgentPopup.show({
onSelect: (type) => {
if (type === 'assistant') {
onCreateAssistant()
}
if (type === 'agent') {
!apiServerRunning && startApiServer()
AgentModalPopup.show({ afterSubmit: afterCreate })
}
}
})
}
return ( return (
<div className="mb-1"> <div className="-mt-[4px] mb-[6px]">
<AddButton onPress={handleAddButtonClick} className="-mt-[1px] mb-[2px]"> <AddButton onClick={handleAddButtonClick}>{t('chat.add.assistant.title')}</AddButton>
{t('chat.add.assistant.title')}
</AddButton>
<AgentModal isOpen={isAgentModalOpen} onClose={onAgentModalClose} afterSubmit={afterCreate} />
</div> </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 AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
import { useActiveSession } from '@renderer/hooks/agents/useActiveSession' import { useActiveSession } from '@renderer/hooks/agents/useActiveSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' 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 { Assistant, Topic } from '@renderer/types'
import type { Tab } from '@renderer/types/chat' import type { Tab } from '@renderer/types/chat'
import { classNames, getErrorMessage, uuid } from '@renderer/utils' import { classNames, getErrorMessage, uuid } from '@renderer/utils'
import { Alert, Skeleton } from 'antd'
import type { FC } from 'react' import type { FC } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -166,16 +166,17 @@ const HomeTabs: FC<Props> = ({
)} )}
{tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />} {tab === 'settings' && isTopicView && <Settings assistant={activeAssistant} />}
{tab === 'settings' && isSessionView && !sessionError && ( {tab === 'settings' && isSessionView && !sessionError && (
<Skeleton isLoaded={!isSessionLoading} className="h-full"> <Skeleton loading={isSessionLoading} active style={{ height: '100%', padding: '16px' }}>
<SessionSettingsTab session={session} update={updateSession} /> <SessionSettingsTab session={session} update={updateSession} />
</Skeleton> </Skeleton>
)} )}
{tab === 'settings' && isSessionView && sessionError && ( {tab === 'settings' && isSessionView && sessionError && (
<div className="w-[var(--assistants-width)] p-2 px-3 pt-4"> <div className="w-[var(--assistants-width)] p-2 px-3 pt-4">
<Alert <Alert
color="danger" type="error"
title={t('agent.session.get.error.failed')} message={t('agent.session.get.error.failed')}
description={getErrorMessage(sessionError)} description={getErrorMessage(sessionError)}
style={{ padding: '10px 15px' }}
/> />
</div> </div>
)} )}

View File

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

View File

@ -1,28 +1,59 @@
import { Button } from '@heroui/react'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup' import { SelectApiModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import { agentModelFilter } from '@renderer/config/models' import { agentModelFilter } from '@renderer/config/models'
import { useApiModel } from '@renderer/hooks/agents/useModel' import { useApiModel } from '@renderer/hooks/agents/useModel'
import { getProviderNameById } from '@renderer/services/ProviderService' import { getProviderNameById } from '@renderer/services/ProviderService'
import type { AgentBaseWithId, ApiModel } from '@renderer/types' import type { AgentBaseWithId, ApiModel } from '@renderer/types'
import { isAgentSessionEntity } from '@renderer/types'
import { isAgentEntity } from '@renderer/types' import { isAgentEntity } from '@renderer/types'
import { getModelFilterByAgentType } from '@renderer/utils/agentSession' import { getModelFilterByAgentType } from '@renderer/utils/agentSession'
import { apiModelAdapter } from '@renderer/utils/model' import { apiModelAdapter } from '@renderer/utils/model'
import type { ButtonProps } from 'antd'
import { Button } from 'antd'
import { ChevronsUpDown } from 'lucide-react' import { ChevronsUpDown } from 'lucide-react'
import type { FC } from 'react' import type { CSSProperties, FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface Props { interface Props {
agentBase: AgentBaseWithId agentBase: AgentBaseWithId
onSelect: (model: ApiModel) => Promise<void> onSelect: (model: ApiModel) => Promise<void>
isDisabled?: boolean 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 { t } = useTranslation()
const model = useApiModel({ id: agent?.model }) 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 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 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 ( return (
<Button <Button
size="sm" size={buttonSize}
variant="light" type="text"
className="nodrag h-[28px] rounded-2xl px-1" className={className}
onPress={onSelectModel} style={mergedStyle}
isDisabled={isDisabled}> onClick={onSelectModel}
<div className="flex items-center gap-1.5 overflow-x-hidden"> disabled={isDisabled}>
<ModelAvatar model={model ? apiModelAdapter(model) : undefined} size={20} /> <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)]"> <span className="truncate text-[var(--color-text)]">
{model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''} {model ? model.name : t('button.select_model')} {providerName ? ' | ' + providerName : ''}
</span> </span>
</div> </div>
<ChevronsUpDown size={14} color="var(--color-icon)" /> <ChevronsUpDown size={iconSize} color="var(--color-icon)" />
</div>
</Button> </Button>
) )
} }

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { BreadcrumbItem, Breadcrumbs } from '@heroui/react'
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar' import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
@ -6,7 +5,7 @@ import { useActiveNode } from '@renderer/hooks/useNotesQuery'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace'
import { findNode } from '@renderer/services/NotesTreeService' import { findNode } from '@renderer/services/NotesTreeService'
import { Dropdown, Input, Tooltip } from 'antd' import { Breadcrumb, Dropdown, Input, Tooltip } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react' import { MoreHorizontal, PanelLeftClose, PanelRightClose, Star } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@ -191,13 +190,14 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
</HStack> </HStack>
<NavbarCenter style={{ flex: 1, minWidth: 0 }}> <NavbarCenter style={{ flex: 1, minWidth: 0 }}>
<BreadcrumbsContainer> <BreadcrumbsContainer>
<Breadcrumbs style={{ borderRadius: 0 }}> <Breadcrumb
{breadcrumbItems.map((item, index) => { separator={'>'}
items={breadcrumbItems.map((item, index) => {
const isLastItem = index === breadcrumbItems.length - 1 const isLastItem = index === breadcrumbItems.length - 1
const isCurrentNote = isLastItem && !item.isFolder const isCurrentNote = isLastItem && !item.isFolder
return {
return ( title: (
<BreadcrumbItem key={item.key} isCurrent={isLastItem}> <div key={item.key} className="flex">
{isCurrentNote ? ( {isCurrentNote ? (
<TitleInputWrapper> <TitleInputWrapper>
<TitleInput <TitleInput
@ -223,10 +223,10 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
{item.title} {item.title}
</BreadcrumbTitle> </BreadcrumbTitle>
)} )}
</BreadcrumbItem> </div>
) )
})} }
</Breadcrumbs> })}></Breadcrumb>
</BreadcrumbsContainer> </BreadcrumbsContainer>
</NavbarCenter> </NavbarCenter>
<NavbarRight style={{ paddingRight: 0 }}> <NavbarRight style={{ paddingRight: 0 }}>
@ -347,13 +347,6 @@ export const BreadcrumbsContainer = styled.div`
max-width: none !important; 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 * { & li:last-child * {
max-width: none !important; max-width: none !important;

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { Input, Tooltip } from '@heroui/react'
import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import type { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import type { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import type { import type {
@ -8,6 +7,7 @@ import type {
UpdateAgentBaseForm UpdateAgentBaseForm
} from '@renderer/types' } from '@renderer/types'
import { AgentConfigurationSchema } from '@renderer/types' import { AgentConfigurationSchema } from '@renderer/types'
import { InputNumber, Tooltip } from 'antd'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -31,34 +31,33 @@ const defaultConfiguration: AgentConfigurationState = AgentConfigurationSchema.p
export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, update }) => { export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, update }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration) const [configuration, setConfiguration] = useState<AgentConfigurationState>(defaultConfiguration)
const [maxTurnsInput, setMaxTurnsInput] = useState<string>(String(defaultConfiguration.max_turns)) const [maxTurnsInput, setMaxTurnsInput] = useState<number>(defaultConfiguration.max_turns)
useEffect(() => { useEffect(() => {
if (!agentBase) { if (!agentBase) {
setConfiguration(defaultConfiguration) setConfiguration(defaultConfiguration)
setMaxTurnsInput(String(defaultConfiguration.max_turns)) setMaxTurnsInput(defaultConfiguration.max_turns)
return return
} }
const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {}) const parsed: AgentConfigurationState = AgentConfigurationSchema.parse(agentBase.configuration ?? {})
setConfiguration(parsed) setConfiguration(parsed)
setMaxTurnsInput(String(parsed.max_turns)) setMaxTurnsInput(parsed.max_turns)
}, [agentBase]) }, [agentBase])
const commitMaxTurns = useCallback(() => { const commitMaxTurns = useCallback(() => {
if (!agentBase) return if (!agentBase) return
const parsedValue = Number.parseInt(maxTurnsInput, 10) if (!Number.isFinite(maxTurnsInput)) {
if (!Number.isFinite(parsedValue)) { setMaxTurnsInput(configuration.max_turns)
setMaxTurnsInput(String(configuration.max_turns))
return return
} }
const sanitized = Math.max(1, parsedValue) const sanitized = Math.max(1, maxTurnsInput)
if (sanitized === configuration.max_turns) { if (sanitized === configuration.max_turns) {
setMaxTurnsInput(String(configuration.max_turns)) setMaxTurnsInput(configuration.max_turns)
return return
} }
const next: AgentConfigurationState = { ...configuration, max_turns: sanitized } const next: AgentConfigurationState = { ...configuration, max_turns: sanitized }
setConfiguration(next) setConfiguration(next)
setMaxTurnsInput(String(sanitized)) setMaxTurnsInput(sanitized)
update({ id: agentBase.id, configuration: next } satisfies UpdateAgentBaseForm) update({ id: agentBase.id, configuration: next } satisfies UpdateAgentBaseForm)
}, [agentBase, configuration, maxTurnsInput, update]) }, [agentBase, configuration, maxTurnsInput, update])
@ -71,27 +70,23 @@ export const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({ agentBase, u
<SettingsItem divider={false}> <SettingsItem divider={false}>
<SettingsTitle <SettingsTitle
actions={ 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" /> <Info size={16} className="text-foreground-400" />
</Tooltip> </Tooltip>
}> }>
{t('agent.settings.advance.maxTurns.label')} {t('agent.settings.advance.maxTurns.label')}
</SettingsTitle> </SettingsTitle>
<div className="flex w-full flex-col gap-2"> <div className="my-2 flex w-full flex-col gap-2">
<Input <InputNumber
type="number"
min={1} min={1}
value={maxTurnsInput} value={maxTurnsInput}
onValueChange={setMaxTurnsInput} onChange={(value) => setMaxTurnsInput(value ?? 1)}
onBlur={commitMaxTurns} onBlur={commitMaxTurns}
onKeyDown={(event) => { onPressEnter={commitMaxTurns}
if (event.key === 'Enter') {
commitMaxTurns()
}
}}
aria-label={t('agent.settings.advance.maxTurns.label')} 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> </div>
</SettingsItem> </SettingsItem>
</SettingsContainer> </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 { TopView } from '@renderer/components/TopView'
import { useAgent } from '@renderer/hooks/agents/useAgent' import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { Alert, Spin } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -71,18 +72,25 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
const ModalContent = () => { const ModalContent = () => {
if (isLoading) { if (isLoading) {
// TODO: use skeleton for better ux // TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return ( return (
<div> <Center flex={1}>
<Alert color="danger" title={t('agent.get.error.failed')} /> <Spin />
</div> </Center>
) )
} }
if (error) {
return (
<Center flex={1}>
<Alert type="error" message={t('agent.get.error.failed')} />
</Center>
)
}
if (!agent) { if (!agent) {
return null return null
} }
return ( return (
<div className="flex w-full flex-1"> <div className="flex w-full flex-1">
<LeftMenu> <LeftMenu>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Input } from '@heroui/react'
import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types' import type { AgentBaseWithId, UpdateAgentBaseForm, UpdateAgentFunctionUnion } from '@renderer/types'
import { Input } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -25,14 +25,13 @@ export const NameSetting = ({ base, update }: NameSettingsProps) => {
<Input <Input
placeholder={t('common.agent_one') + t('common.name')} placeholder={t('common.agent_one') + t('common.name')}
value={name} value={name}
size="sm" onChange={(e) => setName(e.target.value)}
onValueChange={(value) => setName(value)}
onBlur={() => { onBlur={() => {
if (name !== base.name) { if (name !== base.name) {
updateName(name) updateName(name)
} }
}} }}
className="max-w-80 flex-1" className="max-w-70 flex-1"
/> />
</SettingsItem> </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 { useAvailablePlugins, useInstalledPlugins, usePluginActions } from '@renderer/hooks/usePlugins'
import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentFunctionUnion } from '@renderer/types/agent' import type { GetAgentResponse, GetAgentSessionResponse, UpdateAgentFunctionUnion } from '@renderer/types/agent'
import { Card, Segmented } from 'antd'
import type { FC } from 'react' import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { InstalledPluginsList } from './components/InstalledPluginsList' import { InstalledPluginsList } from './components/InstalledPluginsList'
import { PluginBrowser } from './components/PluginBrowser' import { PluginBrowser } from './components/PluginBrowser'
import { SettingsContainer } from './shared'
interface PluginSettingsProps { interface PluginSettingsProps {
agentBase: GetAgentResponse | GetAgentSessionResponse agentBase: GetAgentResponse | GetAgentSessionResponse
@ -16,6 +17,7 @@ interface PluginSettingsProps {
const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => { const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<string>('available')
// Fetch available plugins // Fetch available plugins
const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins() const { agents, commands, skills, loading: loadingAvailable, error: errorAvailable } = useAvailablePlugins()
@ -54,24 +56,28 @@ const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
[uninstall, t] [uninstall, t]
) )
const segmentOptions = useMemo(() => {
return [
{
value: 'available',
label: t('agent.settings.plugins.available.title')
},
{
value: 'installed',
label: t('agent.settings.plugins.installed.title')
}
]
}, [t])
const renderContent = useMemo(() => {
if (activeTab === 'available') {
return ( return (
<SettingsContainer className="pr-0"> <div className="flex h-full flex-col overflow-y-auto pt-4 pr-2">
<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 ? ( {errorAvailable ? (
<Card className="bg-danger-50 dark:bg-danger-900/20"> <Card variant="borderless">
<CardBody>
<p className="text-danger"> <p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorAvailable} {t('agent.settings.plugins.error.load')}: {errorAvailable}
</p> </p>
</CardBody>
</Card> </Card>
) : ( ) : (
<PluginBrowser <PluginBrowser
@ -86,17 +92,16 @@ const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
/> />
)} )}
</div> </div>
</Tab> )
}
<Tab key="installed" title={t('agent.settings.plugins.installed.title')}> return (
<div className="flex h-full flex-col overflow-y-auto pt-4 pr-2"> <div className="flex h-full flex-col overflow-y-auto pt-4 pr-2">
{errorInstalled ? ( {errorInstalled ? (
<Card className="bg-danger-50 dark:bg-danger-900/20"> <Card className="bg-danger-50 dark:bg-danger-900/20">
<CardBody>
<p className="text-danger"> <p className="text-danger">
{t('agent.settings.plugins.error.load')}: {errorInstalled} {t('agent.settings.plugins.error.load')}: {errorInstalled}
</p> </p>
</CardBody>
</Card> </Card>
) : ( ) : (
<InstalledPluginsList <InstalledPluginsList
@ -106,9 +111,34 @@ const PluginSettings: FC<PluginSettingsProps> = ({ agentBase }) => {
/> />
)} )}
</div> </div>
</Tab> )
</Tabs> }, [
</SettingsContainer> 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 { TopView } from '@renderer/components/TopView'
import { useSession } from '@renderer/hooks/agents/useSession' import { useSession } from '@renderer/hooks/agents/useSession'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession' import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { Alert, Spin } from 'antd'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -68,15 +69,21 @@ const SessionSettingPopupContainer: React.FC<SessionSettingPopupParams> = ({ tab
const ModalContent = () => { const ModalContent = () => {
if (isLoading) { if (isLoading) {
// TODO: use skeleton for better ux // TODO: use skeleton for better ux
return <Spinner />
}
if (error) {
return ( return (
<div> <Center flex={1}>
<Alert color="danger" title={t('agent.get.error.failed')} /> <Spin />
</div> </Center>
) )
} }
if (error) {
return (
<Center flex={1}>
<Alert type="error" message={t('agent.get.error.failed')} />
</Center>
)
}
return ( return (
<div className="flex w-full flex-1"> <div className="flex w-full flex-1">
<LeftMenu> <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 { permissionModeCards } from '@renderer/config/agent'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import useScrollPosition from '@renderer/hooks/useScrollPosition' import useScrollPosition from '@renderer/hooks/useScrollPosition'
@ -13,8 +12,9 @@ import type {
UpdateAgentSessionFunction UpdateAgentSessionFunction
} from '@renderer/types' } from '@renderer/types'
import { AgentConfigurationSchema } from '@renderer/types' import { AgentConfigurationSchema } from '@renderer/types'
import { Modal } from 'antd' import { Modal, Tag } from 'antd'
import { ShieldAlert, ShieldCheck, Wrench } from 'lucide-react' import { Alert, Card, Input, Switch } from 'antd'
import { ShieldAlert, Wrench } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -272,47 +272,50 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
const showCaution = card.caution const showCaution = card.caution
return ( return (
<Card <div
key={card.mode} key={card.mode}
isPressable={!disabled} className={`flex flex-col gap-3 overflow-hidden rounded-lg border p-4 transition-colors ${
isDisabled={disabled || isUpdatingMode} isSelected
shadow="none" ? 'border-primary bg-primary-50/30 dark:bg-primary-950/20'
onPress={() => handleSelectPermissionMode(card.mode)} : 'border-default-200 hover:bg-default-50 dark:hover:bg-default-900/20'
className={`border ${ } ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
isSelected ? 'border-primary' : 'border-default-200' onClick={() => !disabled && handleSelectPermissionMode(card.mode)}>
} ${disabled ? 'opacity-60' : ''}`}> {/* Header */}
<CardHeader className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="text-left font-semibold text-sm">{t(card.titleKey, card.titleFallback)}</span> <span className="whitespace-normal break-words text-left font-semibold text-sm">
<span className="text-left text-foreground-500 text-xs"> {t(card.titleKey, card.titleFallback)}
</span>
<span className="whitespace-normal break-words text-left text-foreground-500 text-xs">
{t(card.descriptionKey, card.descriptionFallback)} {t(card.descriptionKey, card.descriptionFallback)}
</span> </span>
</div> </div>
{disabled ? ( {disabled && <Tag color="warning">{t('common.coming_soon', 'Coming soon')}</Tag>}
<Chip color="warning" size="sm" variant="flat"> {isSelected && !disabled && (
{t('common.coming_soon', 'Coming soon')} <Tag color="success">
</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"> <div className="flex items-center gap-1">
<ShieldAlert className="text-danger-600" size={24} /> <span>{t('common.selected', 'Selected')}</span>
<span className="text-danger-600"> </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( {t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning', 'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.' 'Use with caution — all tools will run without asking for approval.'
)} )}
</span> </span>
</div> </div>
) : null} )}
</CardBody> </div>
</Card> </div>
) )
})} })}
</div> </div>
@ -324,23 +327,31 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
</SettingsTitle> </SettingsTitle>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Alert <Alert
color="warning" showIcon
title={t( type="warning"
'agent.settings.tooling.preapproved.warning.title', style={{ padding: '8px 12px' }}
'Pre-approved tools run without manual review.' message={
)} <span className="font-semibold text-sm text-warning">
description={t( {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', 'agent.settings.tooling.preapproved.warning.description',
'Enable only tools you trust. Mode defaults are highlighted automatically.' 'Enable only tools you trust. Mode defaults are highlighted automatically.'
)} )}
</span>
}
/> />
<Input <Input
isClearable allowClear
value={searchTerm} value={searchTerm}
onValueChange={setSearchTerm} onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('agent.settings.tooling.preapproved.search', 'Search tools')} placeholder={t('agent.settings.tooling.preapproved.search', 'Search tools')}
aria-label={t('agent.settings.tooling.preapproved.search', 'Search tools')} aria-label={t('agent.settings.tooling.preapproved.search', 'Search tools')}
className="w-full" className="w-full"
size={'large'}
/> />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{filteredTools.length === 0 ? ( {filteredTools.length === 0 ? (
@ -352,31 +363,34 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
const isAuto = autoToolIds.includes(tool.id) const isAuto = autoToolIds.includes(tool.id)
const isApproved = approvedToolIds.includes(tool.id) const isApproved = approvedToolIds.includes(tool.id)
return ( return (
<Card key={tool.id} shadow="none" className="border border-default-200"> <Card
<CardHeader className="flex items-start justify-between gap-3"> 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"> <div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium text-sm">{tool.name}</span> <span className="truncate font-medium text-sm">{tool.name}</span>
{tool.description ? ( {tool.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{tool.description}</span> <span className="line-clamp-2 whitespace-normal text-foreground-500 text-xs">
{tool.description}
</span>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{isAuto ? ( {isAuto ? (
<Chip size="sm" color="primary" variant="flat"> <Tag color="success">
{t('agent.settings.tooling.preapproved.autoBadge', 'Added by mode')} {t('agent.settings.tooling.preapproved.autoBadge', 'Added by mode')}
</Chip> </Tag>
) : null} ) : null}
{tool.type === 'mcp' ? ( {tool.type === 'mcp' ? (
<Chip size="sm" color="secondary" variant="flat"> <Tag color="default">{t('agent.settings.tooling.preapproved.mcpBadge', 'MCP tool')}</Tag>
{t('agent.settings.tooling.preapproved.mcpBadge', 'MCP tool')}
</Chip>
) : null} ) : null}
{tool.requirePermissions ? ( {tool.requirePermissions ? (
<Chip size="sm" color="warning" variant="flat"> <Tag color="warning">
{t( {t(
'agent.settings.tooling.preapproved.requiresApproval', 'agent.settings.tooling.preapproved.requiresApproval',
'Requires approval when disabled' 'Requires approval when disabled'
)} )}
</Chip> </Tag>
) : null} ) : null}
</div> </div>
</div> </div>
@ -385,21 +399,35 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
defaultValue: `Toggle ${tool.name}`, defaultValue: `Toggle ${tool.name}`,
name: tool.name name: tool.name
})} })}
isSelected={isApproved} checked={isApproved}
isDisabled={isAuto || isUpdatingTools} disabled={isAuto || isUpdatingTools}
size="sm" size="small"
onValueChange={(value) => handleToggleTool(tool.id, value)} onChange={(checked) => handleToggleTool(tool.id, checked)}
/> />
</CardHeader> </div>
}
styles={{
header: {
paddingLeft: '12px',
paddingRight: '12px',
borderBottom: 'none'
},
body: {
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '0px',
paddingBottom: '0px'
}
}}>
{isAuto ? ( {isAuto ? (
<CardBody className="py-0 pb-3"> <div className="py-0 pb-3">
<span className="text-foreground-400 text-xs"> <span className="text-foreground-400 text-xs">
{t( {t(
'agent.settings.tooling.preapproved.autoDescription', 'agent.settings.tooling.preapproved.autoDescription',
'This tool is auto-approved by the current permission mode.' 'This tool is auto-approved by the current permission mode.'
)} )}
</span> </span>
</CardBody> </div>
) : null} ) : null}
</Card> </Card>
) )
@ -427,8 +455,11 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
{availableServers.map((server) => { {availableServers.map((server) => {
const isSelected = selectedMcpIds.includes(server.id) const isSelected = selectedMcpIds.includes(server.id)
return ( return (
<Card key={server.id} shadow="none" className="border border-default-200"> <Card
<CardHeader className="flex items-center justify-between gap-2"> 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"> <div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-sm">{server.name}</span> <span className="truncate font-medium text-sm">{server.name}</span>
{server.description ? ( {server.description ? (
@ -440,13 +471,27 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
defaultValue: `Toggle ${server.name}`, defaultValue: `Toggle ${server.name}`,
name: server.name name: server.name
})} })}
isSelected={isSelected} checked={isSelected}
size="sm" size="small"
isDisabled={!server.isActive || isUpdatingMcp} disabled={!server.isActive || isUpdatingMcp}
onValueChange={(value) => handleToggleMcp(server.id, value)} onChange={(checked) => handleToggleMcp(server.id, checked)}
/>
</div>
}
styles={{
header: {
paddingLeft: '12px',
paddingRight: '12px',
borderBottom: 'none'
},
body: {
paddingLeft: '12px',
paddingRight: '12px',
paddingTop: '0px',
paddingBottom: '0px'
}
}}
/> />
</CardHeader>
</Card>
) )
})} })}
</div> </div>
@ -462,33 +507,47 @@ export const ToolingSettings: FC<AgentToolingSettingsProps> = ({ agentBase, upda
<SettingsItem divider={false}> <SettingsItem divider={false}>
<SettingsTitle>{t('agent.settings.tooling.steps.review.title', 'Step 3 · Review')}</SettingsTitle> <SettingsTitle>{t('agent.settings.tooling.steps.review.title', 'Step 3 · Review')}</SettingsTitle>
<Card shadow="none" className="border border-default-200"> <Card
<CardBody className="flex flex-col gap-2 text-sm"> 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"> <div className="flex flex-wrap gap-3">
<Chip variant="flat" color="primary"> <Tag color="success">
{t('agent.settings.tooling.review.mode', { {t('agent.settings.tooling.review.mode', {
defaultValue: `Mode: ${selectedMode}`, defaultValue: `Mode: ${selectedMode}`,
mode: selectedMode mode: selectedMode
})} })}
</Chip> </Tag>
<Chip variant="flat" color="default"> <Tag color="default">
{t('agent.settings.tooling.review.autoTools', { {t('agent.settings.tooling.review.autoTools', {
defaultValue: `Auto: ${autoCount}`, defaultValue: `Auto: ${autoCount}`,
count: autoCount count: autoCount
})} })}
</Chip> </Tag>
<Chip variant="flat" color="success"> <Tag color="success">
{t('agent.settings.tooling.review.customTools', { {t('agent.settings.tooling.review.customTools', {
defaultValue: `Custom: ${customCount}`, defaultValue: `Custom: ${customCount}`,
count: customCount count: customCount
})} })}
</Chip> </Tag>
<Chip variant="flat" color="warning"> <Tag color="warning">
{t('agent.settings.tooling.review.mcp', { {t('agent.settings.tooling.review.mcp', {
defaultValue: `MCP: ${agentSummary.mcps}`, defaultValue: `MCP: ${agentSummary.mcps}`,
count: agentSummary.mcps count: agentSummary.mcps
})} })}
</Chip> </Tag>
</div> </div>
<span className="text-foreground-500 text-xs"> <span className="text-foreground-500 text-xs">
{t( {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.' 'Changes save automatically. Adjust the steps above any time to fine-tune permissions.'
)} )}
</span> </span>
</CardBody> </div>
</Card> </Card>
</SettingsItem> </SettingsItem>
</SettingsContainer> </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 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 type { FC } from 'react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -33,10 +34,10 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
if (loading) { if (loading) {
return ( return (
<div className="space-y-2"> <div className="flex flex-col space-y-2">
<Skeleton className="h-12 w-full rounded-lg" /> <Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
<Skeleton className="h-12 w-full rounded-lg" /> <Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
<Skeleton className="h-12 w-full rounded-lg" /> <Skeleton.Input active className="w-full" size={'large'} style={{ width: '100%' }} />
</div> </div>
) )
} }
@ -50,50 +51,61 @@ export const InstalledPluginsList: FC<InstalledPluginsListProps> = ({ plugins, o
) )
} }
return ( const columns: TableProps<InstalledPlugin>['columns'] = [
<Table aria-label="Installed plugins table" removeWrapper> {
<TableHeader> title: t('plugins.name'),
<TableColumn>{t('plugins.name')}</TableColumn> dataIndex: 'name',
<TableColumn>{t('plugins.type')}</TableColumn> key: 'name',
<TableColumn>{t('plugins.category')}</TableColumn> render: (_: any, plugin: InstalledPlugin) => (
<TableColumn align="end">{t('plugins.actions')}</TableColumn>
</TableHeader>
<TableBody>
{plugins.map((plugin) => (
<TableRow key={plugin.filename}>
<TableCell>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold text-small">{plugin.metadata.name}</span> <span className="font-semibold text-small">{plugin.metadata.name}</span>
{plugin.metadata.description && ( {plugin.metadata.description && (
<span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span> <span className="line-clamp-1 text-default-400 text-tiny">{plugin.metadata.description}</span>
)} )}
</div> </div>
</TableCell> )
<TableCell> },
<Chip size="sm" variant="flat" color={plugin.type === 'agent' ? 'primary' : 'secondary'}> {
{plugin.type} title: t('plugins.type'),
</Chip> dataIndex: 'type',
</TableCell> key: 'type',
<TableCell> align: 'center',
<Chip size="sm" variant="dot"> 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} {plugin.metadata.category}
</Chip> </Tag>
</TableCell> )
<TableCell> },
{
title: t('plugins.actions'),
key: 'actions',
align: 'center',
render: (_: any, plugin: InstalledPlugin) => (
<Button <Button
size="sm" danger
color="danger" type="text"
variant="light" onClick={() => handleUninstall(plugin)}
isIconOnly loading={uninstallingPlugin === plugin.filename}
onPress={() => handleUninstall(plugin)} disabled={loading}
isLoading={uninstallingPlugin === plugin.filename} icon={<Trash2 className="h-4 w-4" />}
isDisabled={loading}> />
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) )
} }
]
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 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 { Filter, Search } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
import { useEffect, useMemo, useRef, useState } 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 [selectedPlugin, setSelectedPlugin] = useState<PluginMetadata | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const observerTarget = useRef<HTMLDivElement>(null) const observerTarget = useRef<HTMLDivElement>(null)
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false)
// Combine all plugins based on active type // Combine all plugins based on active type
const allPlugins = useMemo(() => { const allPlugins = useMemo(() => {
@ -92,6 +94,68 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
return filteredPlugins.slice(0, displayCount) return filteredPlugins.slice(0, displayCount)
}, [filteredPlugins, 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 const hasMore = displayCount < filteredPlugins.length
// Reset display count when filters change // Reset display count when filters change
@ -169,74 +233,37 @@ export const PluginBrowser: FC<PluginBrowserProps> = ({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Search and Filter */} {/* Search and Filter */}
<div className="relative flex gap-0"> <div className="flex gap-2">
<Input <AntInput
placeholder={t('plugins.search_placeholder')} placeholder={t('plugins.search_placeholder')}
value={searchQuery} value={searchQuery}
onValueChange={handleSearchChange} onChange={(e) => handleSearchChange(e.target.value)}
startContent={<Search className="h-4 w-4 text-default-400" />} prefix={<Search className="h-4 w-4 text-default-400" />}
isClearable
size="md"
className="flex-1"
classNames={{
inputWrapper: 'pr-12'
}}
/> />
<Dropdown placement="bottom-end" classNames={{ content: 'max-h-60 overflow-y-auto p-0' }}> <AntDropdown
<DropdownTrigger> menu={{ items: pluginCategoryMenuItems }}
<Button trigger={['click']}
isIconOnly open={filterDropdownOpen}
variant={selectedCategories.length > 0 ? 'flat' : 'light'} placement="bottomRight"
onOpenChange={setFilterDropdownOpen}>
<AntButton
variant={selectedCategories.length > 0 ? 'filled' : 'outlined'}
color={selectedCategories.length > 0 ? 'primary' : 'default'} color={selectedCategories.length > 0 ? 'primary' : 'default'}
size="sm" size="middle"
className="-translate-y-1/2 absolute top-1/2 right-2 z-10"> icon={<Filter className="h-4 w-4" color="var(--color-text-2)" />}
<Filter className="h-4 w-4" /> />
</Button> </AntDropdown>
</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>
</div> </div>
{/* Type Tabs */} {/* Type Tabs */}
<div className="-mt-3 flex justify-center"> <div className="-mb-3 flex w-full justify-center">
<Tabs selectedKey={activeType} onSelectionChange={handleTypeChange} variant="underlined"> <AntTabs
<Tab key="all" title={t('plugins.all_types')} /> activeKey={activeType}
<Tab key="agent" title={t('plugins.agents')} /> onChange={handleTypeChange}
<Tab key="command" title={t('plugins.commands')} /> items={pluginTypeTabItems}
<Tab key="skill" title={t('plugins.skills')} /> className="w-full"
</Tabs> size="small"
/>
</div> </div>
{/* Result Count */} {/* 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 type { PluginMetadata } from '@renderer/types/plugin'
import { Button, Card, Spin, Tag } from 'antd'
import { upperFirst } from 'lodash' import { upperFirst } from 'lodash'
import { Download, Trash2 } from 'lucide-react' import { Download, Trash2 } from 'lucide-react'
import type { FC } from 'react' import type { FC } from 'react'
@ -17,73 +17,73 @@ export interface PluginCardProps {
export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => { export const PluginCard: FC<PluginCardProps> = ({ plugin, installed, onInstall, onUninstall, loading, onClick }) => {
const { t } = useTranslation() const { t } = useTranslation()
const getTypeTagColor = () => {
if (plugin.type === 'agent') return 'blue'
if (plugin.type === 'skill') return 'green'
return 'default'
}
return ( return (
<Card <Card
className="flex h-full w-full cursor-pointer flex-col border-[0.5px] border-default-200" className="flex h-full w-full cursor-pointer flex-col"
isPressable onClick={onClick}
shadow="none" styles={{
onPress={onClick}> body: { display: 'flex', flexDirection: 'column', height: '100%', padding: '16px' }
<CardHeader className="flex flex-col items-start gap-2 pb-2"> }}>
<div className="flex flex-col items-start gap-2 pb-2">
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<h3 className="truncate font-medium text-small">{plugin.name}</h3> <h3 className="truncate font-medium text-sm">{plugin.name}</h3>
<Chip <Tag color={getTypeTagColor()} className="m-0 text-xs">
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">
{upperFirst(plugin.type)} {upperFirst(plugin.type)}
</Chip> </Tag>
</div>
<Tag className="m-0">{plugin.category}</Tag>
</div> </div>
<Chip size="sm" variant="dot" color="default">
{plugin.category}
</Chip>
</CardHeader>
<CardBody className="flex-1 py-2"> <div className="flex-1 py-2">
<p className="line-clamp-3 text-default-500 text-small">{plugin.description || t('plugins.no_description')}</p> <p className="line-clamp-3 text-gray-500 text-sm">{plugin.description || t('plugins.no_description')}</p>
{plugin.tags && plugin.tags.length > 0 && ( {plugin.tags && plugin.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{plugin.tags.map((tag) => ( {plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered" className="text-tiny"> <Tag key={tag} bordered className="text-xs">
{tag} {tag}
</Chip> </Tag>
))} ))}
</div> </div>
)} )}
</CardBody> </div>
<CardFooter className="pt-2"> <div className="pt-2">
{installed ? ( {installed ? (
<Button <Button
color="danger" danger
variant="flat" type="primary"
size="sm" size="small"
startContent={loading ? <Spinner size="sm" color="current" /> : <Trash2 className="h-4 w-4" />} icon={loading ? <Spin size="small" /> : <Trash2 className="h-4 w-4" />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onUninstall() onUninstall()
}} }}
isDisabled={loading} disabled={loading}
fullWidth> block>
{loading ? t('plugins.uninstalling') : t('plugins.uninstall')} {loading ? t('plugins.uninstalling') : t('plugins.uninstall')}
</Button> </Button>
) : ( ) : (
<Button <Button
color="primary" type="primary"
variant="flat" size="small"
size="sm" icon={loading ? <Spin size="small" /> : <Download className="h-4 w-4" />}
startContent={loading ? <Spinner size="sm" color="current" /> : <Download className="h-4 w-4" />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onInstall() onInstall()
}} }}
isDisabled={loading} disabled={loading}
fullWidth> block>
{loading ? t('plugins.installing') : t('plugins.install')} {loading ? t('plugins.installing') : t('plugins.install')}
</Button> </Button>
)} )}
</CardFooter> </div>
</Card> </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 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 type { FC } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@ -122,34 +112,68 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
const modalContent = ( const modalContent = (
<Modal <Modal
isOpen={isOpen} centered
onClose={onClose} open={isOpen}
size="2xl" onCancel={onClose}
scrollBehavior="inside" styles={{
classNames={{ body: {
wrapper: 'z-[9999]' maxHeight: '60vh',
}}> overflowY: 'auto'
<ModalContent> }
<ModalHeader className="flex flex-col gap-1"> }}
style={{
width: '70%'
}}
title={
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="font-bold text-xl">{plugin.name}</h2> <h2 className="font-bold text-xl">{plugin.name}</h2>
<Chip size="sm" variant="solid" color={plugin.type === 'agent' ? 'primary' : 'secondary'}> <Tag color={plugin.type === 'agent' ? 'magenta' : 'purple'}>{plugin.type}</Tag>
{plugin.type}
</Chip>
</div> </div>
<div className="flex items-center gap-2"> <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} {plugin.category}
</Chip> </Tag>
{plugin.version && ( {plugin.version && <Tag>v{plugin.version}</Tag>}
<Chip size="sm" variant="bordered"> </div>
v{plugin.version} </div>
</Chip> }
footer={
<div className="flex flex-row justify-end gap-4">
<Button type="text" onClick={onClose}>
{t('common.close')}
</Button>
{installed ? (
<Button
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"
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>
)} )}
</div> </div>
</ModalHeader> }>
<div>
<ModalBody>
{/* Description */} {/* Description */}
{plugin.description && ( {plugin.description && (
<div className="mb-4"> <div className="mb-4">
@ -172,9 +196,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
<h3 className="mb-2 font-semibold text-small">Tools</h3> <h3 className="mb-2 font-semibold text-small">Tools</h3>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{plugin.tools.map((tool) => ( {plugin.tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat"> <Tag key={tool}>{tool}</Tag>
{tool}
</Chip>
))} ))}
</div> </div>
</div> </div>
@ -186,9 +208,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
<h3 className="mb-2 font-semibold text-small">Allowed Tools</h3> <h3 className="mb-2 font-semibold text-small">Allowed Tools</h3>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{plugin.allowed_tools.map((tool) => ( {plugin.allowed_tools.map((tool) => (
<Chip key={tool} size="sm" variant="flat"> <Tag key={tool}>{tool}</Tag>
{tool}
</Chip>
))} ))}
</div> </div>
</div> </div>
@ -200,9 +220,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
<h3 className="mb-2 font-semibold text-small">Tags</h3> <h3 className="mb-2 font-semibold text-small">Tags</h3>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{plugin.tags.map((tag) => ( {plugin.tags.map((tag) => (
<Chip key={tag} size="sm" variant="bordered"> <Tag key={tag}>{tag}</Tag>
{tag}
</Chip>
))} ))}
</div> </div>
</div> </div>
@ -242,26 +260,26 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
{isEditing ? ( {isEditing ? (
<> <>
<Button <Button
size="sm" danger
variant="flat" variant="filled"
color="danger" icon={<X className="h-3 w-3" />}
startContent={<X className="h-3 w-3" />} iconPosition="start"
onPress={handleCancelEdit} onClick={handleCancelEdit}
isDisabled={saving}> disabled={saving}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
size="sm"
color="primary" color="primary"
startContent={saving ? <Spinner size="sm" color="current" /> : <Save className="h-3 w-3" />} variant="filled"
onPress={handleSave} icon={saving ? <Spin size="small" /> : <Save className="h-3 w-3" />}
isDisabled={saving}> onClick={handleSave}
{saving ? 'Saving...' : 'Save'} disabled={saving}>
{t('common.save')}
</Button> </Button>
</> </>
) : ( ) : (
<Button size="sm" variant="flat" startContent={<Edit className="h-3 w-3" />} onPress={handleEdit}> <Button variant="filled" icon={<Edit className="h-3 w-3" />} onClick={handleEdit}>
Edit {t('common.edit')}
</Button> </Button>
)} )}
</div> </div>
@ -269,17 +287,17 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
</div> </div>
{contentLoading ? ( {contentLoading ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<Spinner size="sm" /> <Spin size="small" />
</div> </div>
) : contentError ? ( ) : contentError ? (
<div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div> <div className="rounded-md bg-danger-50 p-3 text-danger text-small">{contentError}</div>
) : isEditing ? ( ) : isEditing ? (
<Textarea <Input.TextArea
value={editedContent} value={editedContent}
onValueChange={setEditedContent} onChange={(e) => setEditedContent(e.target.value)}
minRows={20} autoSize={{ minRows: 20 }}
classNames={{ classNames={{
input: 'font-mono text-tiny' textarea: 'font-mono text-tiny'
}} }}
/> />
) : ( ) : (
@ -288,32 +306,7 @@ export const PluginDetailModal: FC<PluginDetailModalProps> = ({
</pre> </pre>
)} )}
</div> </div>
</ModalBody> </div>
<ModalFooter>
<Button variant="light" onPress={onClose}>
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}>
{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}>
{loading ? t('plugins.installing') : t('plugins.install')}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal> </Modal>
) )

View File

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

View File

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

View File

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

View File

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

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