Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex
2025-10-09 12:10:37 +08:00
272 changed files with 25493 additions and 3702 deletions
+2 -2
View File
@@ -9,7 +9,6 @@ import { ErrorBoundary } from './components/ErrorBoundary'
import TabsContainer from './components/Tab/TabContainer'
import NavigationHandler from './handler/NavigationHandler'
import { useNavbarPosition } from './hooks/useNavbar'
import AgentsPage from './pages/agents/AgentsPage'
import CodeToolsPage from './pages/code/CodeToolsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
@@ -20,6 +19,7 @@ import MinAppsPage from './pages/minapps/MinAppsPage'
import NotesPage from './pages/notes/NotesPage'
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage'
import AssistantPresetsPage from './pages/store/assistants/presets/AssistantPresetsPage'
import TranslatePage from './pages/translate/TranslatePage'
const Router: FC = () => {
@@ -30,7 +30,7 @@ const Router: FC = () => {
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/store" element={<AssistantPresetsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
@@ -9,6 +9,7 @@ import { WebSearchSource } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import { convertLinks, flushLinkConverterBuffer } from '@renderer/utils/linkConverter'
import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types'
import type { TextStreamPart, ToolSet } from 'ai'
import { ToolCallChunkHandler } from './handleToolCallChunk'
@@ -24,6 +25,7 @@ export class AiSdkToChunkAdapter {
private accumulate: boolean | undefined
private isFirstChunk = true
private enableWebSearch: boolean = false
private onSessionUpdate?: (sessionId: string) => void
private responseStartTimestamp: number | null = null
private firstTokenTimestamp: number | null = null
@@ -31,11 +33,13 @@ export class AiSdkToChunkAdapter {
private onChunk: (chunk: Chunk) => void,
mcpTools: MCPTool[] = [],
accumulate?: boolean,
enableWebSearch?: boolean
enableWebSearch?: boolean,
onSessionUpdate?: (sessionId: string) => void
) {
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
this.accumulate = accumulate
this.enableWebSearch = enableWebSearch || false
this.onSessionUpdate = onSessionUpdate
}
private markFirstTokenIfNeeded() {
@@ -119,6 +123,17 @@ export class AiSdkToChunkAdapter {
) {
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
switch (chunk.type) {
case 'raw': {
const agentRawMessage = chunk.rawValue as ClaudeCodeRawValue
if (agentRawMessage.type === 'init' && agentRawMessage.session_id) {
this.onSessionUpdate?.(agentRawMessage.session_id)
}
this.onChunk({
type: ChunkType.RAW,
content: agentRawMessage
})
break
}
// === 文本相关事件 ===
case 'text-start':
this.onChunk({
+3 -7
View File
@@ -14,6 +14,7 @@ import { addSpan, endSpan } from '@renderer/services/SpanManagerService'
import type { StartSpanParams } from '@renderer/trace/types/ModelSpanEntity'
import type { Assistant, GenerateImageParams, Model, Provider } from '@renderer/types'
import type { AiSdkModel, StreamTextParams } from '@renderer/types/aiCoreTypes'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { type ImageModel, type LanguageModel, type Provider as AiSdkProvider, wrapLanguageModel } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
@@ -22,7 +23,6 @@ import type { CompletionsParams, CompletionsResult } from './legacy/middleware/s
import type { AiSdkMiddlewareConfig } from './middleware/AiSdkMiddlewareBuilder'
import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
import { buildPlugins } from './plugins/PluginBuilder'
import { buildClaudeCodeSystemMessage } from './provider/config/anthropic'
import { createAiSdkProvider } from './provider/factory'
import {
getActualProvider,
@@ -123,13 +123,9 @@ export default class ModernAiProvider {
}
if (this.actualProvider.id === 'anthropic' && this.actualProvider.authType === 'oauth') {
const claudeCodeSystemMessage = buildClaudeCodeSystemMessage(params.system)
const claudeCodeSystemMessage = buildClaudeCodeSystemModelMessage(params.system)
params.system = undefined // 清除原有system,避免重复
if (Array.isArray(params.messages)) {
params.messages = [...claudeCodeSystemMessage, ...params.messages]
} else {
params.messages = claudeCodeSystemMessage
}
params.messages = [...claudeCodeSystemMessage, ...(params.messages || [])]
}
if (config.topicId && (await preferenceService.get('app.developer_mode.enabled'))) {
@@ -66,6 +66,7 @@ import {
mcpToolsToAnthropicTools
} from '@renderer/utils/mcp-tools'
import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find'
import { buildClaudeCodeSystemMessage, getSdkClient } from '@shared/anthropic'
import { t } from 'i18next'
import type { GenericChunk } from '../../middleware/schemas'
@@ -84,8 +85,8 @@ export class AnthropicAPIClient extends BaseApiClient<
ToolUnion
> {
oauthToken: string | undefined = undefined
isOAuthMode: boolean = false
sdkInstance: Anthropic | AnthropicVertex | undefined = undefined
constructor(provider: Provider) {
super(provider)
}
@@ -94,84 +95,25 @@ export class AnthropicAPIClient extends BaseApiClient<
if (this.sdkInstance) {
return this.sdkInstance
}
if (this.provider.authType === 'oauth') {
if (!this.oauthToken) {
throw new Error('OAuth token is not available')
}
this.sdkInstance = new Anthropic({
authToken: this.oauthToken,
baseURL: 'https://api.anthropic.com',
dangerouslyAllowBrowser: true,
defaultHeaders: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'oauth-2025-04-20'
// ...this.provider.extra_headers
}
})
} else {
this.sdkInstance = new Anthropic({
apiKey: this.apiKey,
baseURL: this.getBaseURL(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
'anthropic-beta': 'output-128k-2025-02-19',
...this.provider.extra_headers
}
})
this.oauthToken = await window.api.anthropic_oauth.getAccessToken()
}
this.sdkInstance = getSdkClient(this.provider, this.oauthToken)
return this.sdkInstance
}
private buildClaudeCodeSystemMessage(system?: string | Array<TextBlockParam>): string | Array<TextBlockParam> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
if (!system) {
return defaultClaudeCodeSystem
}
if (typeof system === 'string') {
if (system.trim() === defaultClaudeCodeSystem) {
return system
}
return [
{
type: 'text',
text: defaultClaudeCodeSystem
},
{
type: 'text',
text: system
}
]
}
if (system[0].text.trim() != defaultClaudeCodeSystem) {
system.unshift({
type: 'text',
text: defaultClaudeCodeSystem
})
}
return system
}
override async createCompletions(
payload: AnthropicSdkParams,
options?: Anthropic.RequestOptions
): Promise<AnthropicSdkRawOutput> {
if (this.provider.authType === 'oauth') {
this.oauthToken = await window.api.anthropic_oauth.getAccessToken()
this.isOAuthMode = true
logger.info('[Anthropic Provider] Using OAuth token for authentication')
payload.system = this.buildClaudeCodeSystemMessage(payload.system)
payload.system = buildClaudeCodeSystemMessage(payload.system)
}
const sdk = (await this.getSdkInstance()) as Anthropic
if (payload.stream) {
return sdk.messages.stream(payload, options)
}
return await sdk.messages.create(payload, options)
return sdk.messages.create(payload, options)
}
// @ts-ignore sdk未提供
@@ -181,14 +123,8 @@ export class AnthropicAPIClient extends BaseApiClient<
}
override async listModels(): Promise<Anthropic.ModelInfo[]> {
if (this.provider.authType === 'oauth') {
this.oauthToken = await window.api.anthropic_oauth.getAccessToken()
this.isOAuthMode = true
logger.info('[Anthropic Provider] Using OAuth token for authentication')
}
const sdk = (await this.getSdkInstance()) as Anthropic
const response = await sdk.models.list()
return response.data
}
@@ -1,24 +0,0 @@
import type { SystemModelMessage } from 'ai'
export function buildClaudeCodeSystemMessage(system?: string): Array<SystemModelMessage> {
const defaultClaudeCodeSystem = `You are Claude Code, Anthropic's official CLI for Claude.`
if (!system || system.trim() === defaultClaudeCodeSystem) {
return [
{
role: 'system',
content: defaultClaudeCodeSystem
}
]
}
return [
{
role: 'system',
content: defaultClaudeCodeSystem
},
{
role: 'system',
content: system
}
]
}
@@ -79,9 +79,37 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
/**
* 格式化provider的API Host
*/
function formatAnthropicApiHost(host: string): string {
const trimmedHost = host?.trim()
if (!trimmedHost) {
return ''
}
if (trimmedHost.endsWith('/')) {
return trimmedHost
}
if (trimmedHost.endsWith('/v1')) {
return `${trimmedHost}/`
}
return formatApiHost(trimmedHost)
}
function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider }
if (formatted.type === 'gemini') {
if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatAnthropicApiHost(formatted.anthropicApiHost)
}
if (formatted.type === 'anthropic') {
const baseHost = formatted.anthropicApiHost || formatted.apiHost
formatted.apiHost = formatAnthropicApiHost(baseHost)
if (!formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatted.apiHost
}
} else if (formatted.type === 'gemini') {
formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta')
} else {
formatted.apiHost = formatApiHost(formatted.apiHost)
+245
View File
@@ -0,0 +1,245 @@
import { loggerService } from '@logger'
import { formatAgentServerError } from '@renderer/utils/error'
import {
AddAgentForm,
AgentServerErrorSchema,
ApiModelsFilter,
ApiModelsResponse,
ApiModelsResponseSchema,
CreateAgentRequest,
CreateAgentResponse,
CreateAgentResponseSchema,
CreateSessionForm,
CreateSessionRequest,
GetAgentResponse,
GetAgentResponseSchema,
GetAgentSessionResponse,
GetAgentSessionResponseSchema,
ListAgentSessionsResponse,
ListAgentSessionsResponseSchema,
type ListAgentsResponse,
ListAgentsResponseSchema,
objectEntries,
objectKeys,
UpdateAgentForm,
UpdateAgentRequest,
UpdateAgentResponse,
UpdateAgentResponseSchema,
UpdateSessionForm,
UpdateSessionRequest
} from '@types'
import axios, { Axios, AxiosRequestConfig, isAxiosError } from 'axios'
import { ZodError } from 'zod'
type ApiVersion = 'v1'
const logger = loggerService.withContext('AgentApiClient')
// const logger = loggerService.withContext('AgentClient')
const processError = (error: unknown, fallbackMessage: string) => {
logger.error(fallbackMessage, error as Error)
if (isAxiosError(error)) {
const result = AgentServerErrorSchema.safeParse(error.response?.data)
if (result.success) {
return new Error(formatAgentServerError(result.data))
}
} else if (error instanceof ZodError) {
return error
}
return new Error(fallbackMessage, { cause: error })
}
export class AgentApiClient {
private axios: Axios
private apiVersion: ApiVersion = 'v1'
constructor(config: AxiosRequestConfig, apiVersion?: ApiVersion) {
if (!config.baseURL || !config.headers?.Authorization) {
throw new Error('Please pass in baseUrl and Authroization header.')
}
if (config.baseURL.endsWith('/')) {
throw new Error('baseURL should not end with /')
}
this.axios = axios.create(config)
if (apiVersion) {
this.apiVersion = apiVersion
}
}
public agentPaths = {
base: `/${this.apiVersion}/agents`,
withId: (id: string) => `/${this.apiVersion}/agents/${id}`
}
public getSessionPaths = (agentId: string) => ({
base: `/${this.apiVersion}/agents/${agentId}/sessions`,
withId: (id: string) => `/${this.apiVersion}/agents/${agentId}/sessions/${id}`
})
public getSessionMessagesPaths = (agentId: string, sessionId: string) => ({
base: `/${this.apiVersion}/agents/${agentId}/sessions/${sessionId}/messages`,
withId: (id: number) => `/${this.apiVersion}/agents/${agentId}/sessions/${sessionId}/messages/${id}`
})
public getModelsPath = (props?: ApiModelsFilter) => {
const base = `/${this.apiVersion}/models`
if (!props) return base
if (objectKeys(props).length > 0) {
const params = objectEntries(props)
.map(([key, value]) => `${key}=${value}`)
.join('&')
return `${base}?${params}`
} else {
return base
}
}
public async listAgents(): Promise<ListAgentsResponse> {
const url = this.agentPaths.base
try {
const response = await this.axios.get(url)
const result = ListAgentsResponseSchema.safeParse(response.data)
if (!result.success) {
throw new Error('Not a valid Agents array.')
}
return result.data
} catch (error) {
throw processError(error, 'Failed to list agents.')
}
}
public async createAgent(form: AddAgentForm): Promise<CreateAgentResponse> {
const url = this.agentPaths.base
try {
const payload = form satisfies CreateAgentRequest
const response = await this.axios.post(url, payload)
const data = CreateAgentResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to create agent.')
}
}
public async getAgent(id: string): Promise<GetAgentResponse> {
const url = this.agentPaths.withId(id)
try {
const response = await this.axios.get(url)
const data = GetAgentResponseSchema.parse(response.data)
if (data.id !== id) {
throw new Error('Agent ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to get agent.')
}
}
public async deleteAgent(id: string): Promise<void> {
const url = this.agentPaths.withId(id)
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete agent.')
}
}
public async updateAgent(form: UpdateAgentForm): Promise<UpdateAgentResponse> {
const url = this.agentPaths.withId(form.id)
try {
const payload = form satisfies UpdateAgentRequest
const response = await this.axios.patch(url, payload)
const data = UpdateAgentResponseSchema.parse(response.data)
if (data.id !== form.id) {
throw new Error('Agent ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to updateAgent.')
}
}
public async listSessions(agentId: string): Promise<ListAgentSessionsResponse> {
const url = this.getSessionPaths(agentId).base
try {
const response = await this.axios.get(url)
const result = ListAgentSessionsResponseSchema.safeParse(response.data)
if (!result.success) {
throw new Error('Not a valid Sessions array.')
}
return result.data
} catch (error) {
throw processError(error, 'Failed to list sessions.')
}
}
public async createSession(agentId: string, session: CreateSessionForm): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).base
try {
const payload = session satisfies CreateSessionRequest
const response = await this.axios.post(url, payload)
const data = GetAgentSessionResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to add session.')
}
}
public async getSession(agentId: string, sessionId: string): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).withId(sessionId)
try {
const response = await this.axios.get(url)
// const data = GetAgentSessionResponseSchema.parse(response.data)
// TODO: enable validation
const data = response.data
if (sessionId !== data.id) {
throw new Error('Session ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to get session.')
}
}
public async deleteSession(agentId: string, sessionId: string): Promise<void> {
const url = this.getSessionPaths(agentId).withId(sessionId)
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete session.')
}
}
public async deleteSessionMessage(agentId: string, sessionId: string, messageId: number): Promise<void> {
const url = this.getSessionMessagesPaths(agentId, sessionId).withId(messageId)
try {
await this.axios.delete(url)
} catch (error) {
throw processError(error, 'Failed to delete session message.')
}
}
public async updateSession(agentId: string, session: UpdateSessionForm): Promise<GetAgentSessionResponse> {
const url = this.getSessionPaths(agentId).withId(session.id)
try {
const payload = session satisfies UpdateSessionRequest
const response = await this.axios.patch(url, payload)
const data = GetAgentSessionResponseSchema.parse(response.data)
if (session.id !== data.id) {
throw new Error('Session ID mismatch in response')
}
return data
} catch (error) {
throw processError(error, 'Failed to update session.')
}
}
public async getModels(props?: ApiModelsFilter): Promise<ApiModelsResponse> {
const url = this.getModelsPath(props)
try {
const response = await this.axios.get(url)
const data = ApiModelsResponseSchema.parse(response.data)
return data
} catch (error) {
throw processError(error, 'Failed to get models.')
}
}
}
+13 -7
View File
@@ -12,17 +12,23 @@
@import '../fonts/country-flag-fonts/flag.css';
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
/* margin: 0; */
font-weight: normal;
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
/* margin: 0; */
font-weight: normal;
}
}
.lucide:not(.lucide-custom) {
color: var(--color-icon);
}
}
*:focus {
outline: none;
outline-style: none;
}
* {
-webkit-tap-highlight-color: transparent;
@@ -0,0 +1,28 @@
import { Avatar, cn } from '@heroui/react'
import { getModelLogo } from '@renderer/config/models'
import { 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 ? getModelLogo(model.id) : 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>
)
}
@@ -0,0 +1,22 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import React from 'react'
import EmojiPicker from '../EmojiPicker'
type Props = {
emoji: string
onPick: (emoji: string) => void
}
export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return (
<Popover>
<PopoverTrigger>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly />
</PopoverTrigger>
<PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
</PopoverContent>
</Popover>
)
}
@@ -6,7 +6,7 @@ import { first } from 'lodash'
import type { FC } from 'react'
interface Props {
model: Model
model?: Model
size: number
props?: AvatarProps
className?: string
@@ -6,6 +6,7 @@ interface ContextMenuProps {
children: React.ReactNode
}
// FIXME: Why does this component name look like a generic component but is not customizable at all?
const ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {
const { t } = useTranslation()
const [selectedText, setSelectedText] = useState<string | undefined>(undefined)
@@ -6,7 +6,7 @@ import type { FC, MouseEvent } from 'react'
import styled from 'styled-components'
import IndicatorLight from './IndicatorLight'
import SelectModelPopup from './Popups/SelectModelPopup'
import { SelectModelPopup } from './Popups/SelectModelPopup'
import CustomTag from './Tags/CustomTag'
interface Props {
@@ -4,7 +4,7 @@ import type { Model } from '@renderer/types'
import { useCallback, useMemo } from 'react'
import ModelAvatar from './Avatar/ModelAvatar'
import SelectModelPopup from './Popups/SelectModelPopup'
import { SelectModelPopup } from './Popups/SelectModelPopup'
type Props = {
model: Model
@@ -1,12 +1,12 @@
import { RowFlex } from '@cherrystudio/ui'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets'
import { useTimer } from '@renderer/hooks/useTimer'
import { useSystemAgents } from '@renderer/pages/agents'
import { useSystemAssistantPresets } from '@renderer/pages/store/assistants/presets'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { Agent, Assistant } from '@renderer/types'
import type { Assistant, AssistantPreset } from '@renderer/types'
import { uuid } from '@renderer/utils'
import type { InputRef } from 'antd'
import { Divider, Input, Modal, Tag } from 'antd'
@@ -26,30 +26,30 @@ interface Props {
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { agents: userAgents } = useAgents()
const { presets: userPresets } = useAssistantPresets()
const [searchText, setSearchText] = useState('')
const { defaultAssistant } = useDefaultAssistant()
const { assistants, addAssistant } = useAssistants()
const inputRef = useRef<InputRef>(null)
const systemAgents = useSystemAgents()
const systemPresets = useSystemAssistantPresets()
const loadingRef = useRef(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const { setTimeoutTimer } = useTimer()
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
const list = [defaultAssistant, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
const presets = useMemo(() => {
const allPresets = [...userPresets, ...systemPresets] as AssistantPreset[]
const list = [defaultAssistant, ...allPresets.filter((preset) => !assistants.map((a) => a.id).includes(preset.id))]
const filtered = searchText
? list.filter(
(agent) =>
agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
agent.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
(preset) =>
preset.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()) ||
preset.description?.toLowerCase().includes(searchText.trim().toLocaleLowerCase())
)
: list
if (searchText.trim()) {
const newAgent: Agent = {
const newAgent: AssistantPreset = {
id: 'new',
name: searchText.trim(),
prompt: '',
@@ -60,15 +60,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
return [newAgent, ...filtered]
}
return filtered
}, [assistants, defaultAssistant, searchText, systemAgents, userAgents])
}, [assistants, defaultAssistant, searchText, systemPresets, userPresets])
// 重置选中索引当搜索或列表内容变更时
useEffect(() => {
setSelectedIndex(0)
}, [agents.length, searchText])
}, [presets.length, searchText])
const onCreateAssistant = useCallback(
async (agent: Agent) => {
async (preset: AssistantPreset) => {
if (loadingRef.current) {
return
}
@@ -76,11 +76,11 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
loadingRef.current = true
let assistant: Assistant
if (agent.id === 'default') {
assistant = { ...agent, id: uuid() }
if (preset.id === 'default') {
assistant = { ...preset, id: uuid() }
addAssistant(assistant)
} else {
assistant = await createAssistantFromAgent(agent)
assistant = await createAssistantFromAgent(preset)
}
setTimeoutTimer('onCreateAssistant', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
@@ -94,28 +94,28 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
const displayedAgents = take(agents, 100)
const displayedPresets = take(presets, 100)
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev >= displayedAgents.length - 1 ? 0 : prev + 1))
setSelectedIndex((prev) => (prev >= displayedPresets.length - 1 ? 0 : prev + 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev <= 0 ? displayedAgents.length - 1 : prev - 1))
setSelectedIndex((prev) => (prev <= 0 ? displayedPresets.length - 1 : prev - 1))
break
case 'Enter':
case 'NumpadEnter':
// 如果焦点在输入框且有搜索内容,则默认选择第一项
if (document.activeElement === inputRef.current?.input && searchText.trim()) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
onCreateAssistant(displayedPresets[selectedIndex])
}
// 否则选择当前选中项
else if (selectedIndex >= 0 && selectedIndex < displayedAgents.length) {
else if (selectedIndex >= 0 && selectedIndex < displayedPresets.length) {
e.preventDefault()
onCreateAssistant(displayedAgents[selectedIndex])
onCreateAssistant(displayedPresets[selectedIndex])
}
break
}
@@ -123,14 +123,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, agents, searchText, onCreateAssistant])
}, [open, selectedIndex, presets, searchText, onCreateAssistant])
// 确保选中项在可视区域
useEffect(() => {
if (containerRef.current) {
const agentItems = containerRef.current.querySelectorAll('.agent-item')
if (agentItems[selectedIndex]) {
agentItems[selectedIndex].scrollIntoView({
const presetItems = containerRef.current.querySelectorAll('.agent-item')
if (presetItems[selectedIndex]) {
presetItems[selectedIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
@@ -194,19 +194,19 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</RowFlex>
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
<Container ref={containerRef}>
{take(agents, 100).map((agent, index) => (
{take(presets, 100).map((preset, index) => (
<AgentItem
key={agent.id}
onClick={() => onCreateAssistant(agent)}
className={`agent-item ${agent.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
key={preset.id}
onClick={() => onCreateAssistant(preset)}
className={`agent-item ${preset.id === 'default' ? 'default' : ''} ${index === selectedIndex ? 'keyboard-selected' : ''}`}
onMouseEnter={() => setSelectedIndex(index)}>
<RowFlex className="max-w-full items-center gap-[5px] overflow-hidden">
<EmojiIcon emoji={agent.emoji || ''} />
<span className="text-nowrap">{agent.name}</span>
<EmojiIcon emoji={preset.emoji || ''} />
<span className="text-nowrap">{preset.name}</span>
</RowFlex>
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
{agent.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
{preset.id === 'default' && <Tag color="green">{t('assistants.presets.tag.system')}</Tag>}
{preset.type === 'agent' && <Tag color="orange">{t('assistants.presets.tag.agent')}</Tag>}
{preset.id === 'new' && <Tag color="green">{t('assistants.presets.tag.new')}</Tag>}
</AgentItem>
))}
</Container>
@@ -0,0 +1,506 @@
import { FreeTrialModelTag } from '@renderer/components/FreeTrialModelTag'
import { HStack } from '@renderer/components/Layout'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { getModelLogo } from '@renderer/config/models'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { getModelUniqId } from '@renderer/services/ModelService'
import { getProviderNameById } from '@renderer/services/ProviderService'
import { AdaptedApiModel, ApiModel, ApiModelsFilter, Model, ModelType, objectEntries } from '@renderer/types'
import { classNames, filterModelsByKeywords } from '@renderer/utils'
import { apiModelAdapter, getModelTags } from '@renderer/utils/model'
import { Avatar, Divider, Empty, Modal } from 'antd'
import { first, groupBy, sortBy } from 'lodash'
import React, {
startTransition,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
import styled from 'styled-components'
import { useModelTagFilter } from './filters'
import SelectModelSearchBar from './searchbar'
import TagFilterSection from './TagFilterSection'
import { FlatListApiItem, FlatListApiModel } from './types'
const PAGE_SIZE = 12
const ITEM_HEIGHT = 36
interface PopupParams {
model?: ApiModel
/** Api models filter */
apiFilter?: ApiModelsFilter
/** model filter */
modelFilter?: (model: Model) => boolean
/** Show tag filter section */
showTagFilter?: boolean
}
interface Props extends PopupParams {
resolve: (value: ApiModel | undefined) => void
}
export type FilterType = Exclude<ModelType, 'text'> | 'free'
// const logger = loggerService.withContext('SelectModelPopup')
const PopupContainer: React.FC<Props> = ({ model, apiFilter, modelFilter, showTagFilter = true, resolve }) => {
const [open, setOpen] = useState(true)
const listRef = useRef<DynamicVirtualListRef>(null)
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const { models, isLoading } = useApiModels(apiFilter)
const adaptedModels = models.map((model) => apiModelAdapter(model))
// 当前选中的模型ID
const currentModelId = model ? model.id : ''
// 管理滚动和焦点状态
const [focusedItemKey, _setFocusedItemKey] = useState('')
const [isMouseOver, setIsMouseOver] = useState(false)
const preventScrollToIndex = useRef(false)
const setFocusedItemKey = useCallback((key: string) => {
startTransition(() => {
_setFocusedItemKey(key)
})
}, [])
const { tagSelection, selectedTags, tagFilter, toggleTag } = useModelTagFilter()
// 计算要显示的可用标签列表
const availableTags = useMemo(() => {
return objectEntries(getModelTags(adaptedModels))
.filter(([, state]) => state)
.map(([tag]) => tag)
}, [adaptedModels])
// 根据输入的文本筛选模型
const searchFilter = useCallback(
(models: AdaptedApiModel[]) => {
if (searchText.trim()) {
models = filterModelsByKeywords(searchText, models)
}
return sortBy(models, ['group', 'name'])
},
[searchText]
)
// 创建模型列表项
const createModelItem = useCallback(
(model: AdaptedApiModel): FlatListApiModel => {
const modelId = getModelUniqId(model)
const isCherryAi = model.provider === 'cherryai'
return {
key: modelId,
type: 'model',
name: (
<ModelName>
<HStack alignItems="center">{model.name}</HStack>
{isCherryAi && <FreeTrialModelTag model={model} showLabel={false} />}
</ModelName>
),
tags: (
<TagsContainer>
<ModelTagsWithLabel model={model} size={11} showLabel={true} />
</TagsContainer>
),
icon: (
<Avatar src={getModelLogo(model.id || '')} size={24}>
{first(model.name) || 'M'}
</Avatar>
),
model,
isSelected: modelId === currentModelId
}
},
[currentModelId]
)
// 构建扁平化列表数据,并派生出可选择的模型项
const { listItems, modelItems } = useMemo(() => {
const items: FlatListApiItem[] = []
const finalModelFilter = (model: AdaptedApiModel) => {
const _tagFilter = !showTagFilter || tagFilter(model)
const _modelFilter = modelFilter === undefined || modelFilter(model)
return _tagFilter && _modelFilter
}
// 筛选模型
const filteredModels = searchFilter(adaptedModels).filter(finalModelFilter)
// 按 provider 分组
const groups = groupBy(filteredModels, (model) => model.provider) as Record<string, AdaptedApiModel[]>
objectEntries(groups).forEach(([key, models]) => {
items.push({
key: key ?? 'Unknown',
type: 'group',
name: getProviderNameById(key ?? 'Unknown'),
isSelected: false
})
items.push(...models.map((m) => createModelItem(m)))
})
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = items.filter((item) => item.type === 'model')
return { listItems: items, modelItems }
}, [searchFilter, adaptedModels, showTagFilter, tagFilter, createModelItem, modelFilter])
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
}, [listItems.length])
// 处理程序化滚动(加载、搜索开始、搜索清空、tag 筛选)
useLayoutEffect(() => {
if (isLoading) return
if (preventScrollToIndex.current) {
preventScrollToIndex.current = false
return
}
let targetItemKey: string | undefined
// 启动搜索或 tag 筛选时,滚动到第一个 item
if (searchText || selectedTags.length > 0) {
targetItemKey = modelItems[0]?.key
}
// 初始加载或清空搜索时,滚动到 selected item
else {
targetItemKey = modelItems.find((item) => item.isSelected)?.key
}
if (targetItemKey) {
setFocusedItemKey(targetItemKey)
const index = listItems.findIndex((item) => item.key === targetItemKey)
if (index >= 0) {
// FIXME: 手动计算偏移量,给 scroller 增加了 scrollPaddingStart 之后,
// scrollToIndex 不能准确滚动到 item 中心,但是又需要 padding 来改善体验。
const targetScrollTop = index * ITEM_HEIGHT - listHeight / 2
listRef.current?.scrollToOffset(targetScrollTop, {
align: 'start',
behavior: 'auto'
})
}
}
}, [searchText, listItems, modelItems, setFocusedItemKey, listHeight, selectedTags.length, isLoading])
const handleItemClick = useCallback(
(item: FlatListApiItem) => {
if (item.type === 'model') {
resolve(item.model.origin)
setOpen(false)
}
},
[resolve]
)
// 处理键盘导航
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const modelCount = modelItems.length
if (!open || modelCount === 0 || e.isComposing) return
// 键盘操作时禁用鼠标 hover
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)
}
// 当前聚焦的模型 index
const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey)
let nextIndex = -1
switch (e.key) {
case 'ArrowUp': {
nextIndex = (currentIndex < 0 ? 0 : currentIndex - 1 + modelCount) % modelCount
break
}
case 'ArrowDown': {
nextIndex = (currentIndex < 0 ? 0 : currentIndex + 1) % modelCount
break
}
case 'PageUp': {
nextIndex = Math.max(0, (currentIndex < 0 ? 0 : currentIndex) - PAGE_SIZE)
break
}
case 'PageDown': {
nextIndex = Math.min(modelCount - 1, (currentIndex < 0 ? 0 : currentIndex) + PAGE_SIZE)
break
}
case 'Enter':
if (currentIndex >= 0) {
const selectedItem = modelItems[currentIndex]
if (selectedItem) {
handleItemClick(selectedItem)
}
}
break
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
resolve(undefined)
break
}
// 没有键盘导航,直接返回
if (nextIndex < 0) return
const nextKey = modelItems[nextIndex]?.key || ''
if (nextKey) {
setFocusedItemKey(nextKey)
const index = listItems.findIndex((item) => item.key === nextKey)
if (index >= 0) {
listRef.current?.scrollToIndex(index, { align: 'auto' })
}
}
},
[modelItems, open, focusedItemKey, resolve, handleItemClick, setFocusedItemKey, listItems]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
const onCancel = useCallback(() => {
setOpen(false)
}, [])
const onAfterClose = useCallback(async () => {
resolve(undefined)
SelectApiModelPopup.hide()
}, [resolve])
const getItemKey = useCallback((index: number) => listItems[index].key, [listItems])
const estimateSize = useCallback(() => ITEM_HEIGHT, [])
const isSticky = useCallback((index: number) => listItems[index].type === 'group', [listItems])
const rowRenderer = useCallback(
(item: FlatListApiItem) => {
const isFocused = item.key === focusedItemKey
if (item.type === 'group') {
return (
<GroupItem>
{item.name}
{item.actions}
</GroupItem>
)
}
return (
<ModelItem
className={classNames({
focused: isFocused,
selected: item.isSelected
})}
onClick={() => handleItemClick(item)}
onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}>
<ModelItemLeft>
{item.icon}
{item.name}
{item.tags}
</ModelItemLeft>
</ModelItem>
)
},
[focusedItemKey, handleItemClick, setFocusedItemKey]
)
return (
<Modal
centered
open={open}
onCancel={onCancel}
afterClose={onAfterClose}
width={600}
transitionName="animation-move-down"
styles={{
content: {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 16
},
body: {
maxHeight: 'inherit',
padding: 0
}
}}
closeIcon={null}
footer={null}>
{/* 搜索框 */}
<SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{showTagFilter && (
<>
<TagFilterSection availableTags={availableTags} tagSelection={tagSelection} onToggleTag={toggleTag} />
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
</>
)}
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
<DynamicVirtualList
ref={listRef}
list={listItems}
size={listHeight}
getItemKey={getItemKey}
estimateSize={estimateSize}
isSticky={isSticky}
scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度
overscan={5}
scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}>
{rowRenderer}
</DynamicVirtualList>
</ListContainer>
) : (
<EmptyState>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
</EmptyState>
)}
</Modal>
)
}
const ListContainer = styled.div`
position: relative;
overflow: hidden;
`
const GroupItem = styled.div`
display: flex;
align-items: center;
gap: 8px;
position: relative;
line-height: 1;
font-size: 12px;
font-weight: normal;
height: ${ITEM_HEIGHT}px;
padding: 5px 18px;
color: var(--color-text-3);
z-index: 1;
background: var(--modal-background);
.action-icon {
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1 !important;
}
}
&:hover .action-icon {
opacity: 0.3;
}
`
const ModelItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
font-size: 14px;
padding: 0 8px;
margin: 1px 8px;
height: ${ITEM_HEIGHT - 2}px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.1s ease;
&.focused {
background-color: var(--color-background-mute);
}
&.selected {
&::before {
content: '';
display: block;
position: absolute;
left: -1px;
top: 13%;
width: 3px;
height: 74%;
background: var(--color-primary-soft);
border-radius: 8px;
}
}
.pin-icon {
opacity: 0;
}
&:hover .pin-icon {
opacity: 0.3;
}
`
const ModelItemLeft = styled.div`
display: flex;
align-items: center;
width: 100%;
overflow: hidden;
padding-right: 26px;
.anticon {
min-width: auto;
flex-shrink: 0;
}
`
const ModelName = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin: 0 8px;
min-width: 0;
gap: 5px;
`
const TagsContainer = styled.div`
display: flex;
justify-content: flex-end;
min-width: 80px;
max-width: 180px;
overflow: hidden;
flex-shrink: 0;
`
const EmptyState = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 200px;
`
const TopViewKey = 'SelectModelPopup'
export class SelectApiModelPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(params: PopupParams) {
return new Promise<ApiModel | undefined>((resolve) => {
TopView.show(<PopupContainer {...params} resolve={(v) => resolve(v)} />, TopViewKey)
})
}
}
@@ -1,3 +1,2 @@
import { SelectModelPopup } from './popup'
export default SelectModelPopup
export { SelectApiModelPopup } from './api-model-popup'
export { SelectModelPopup } from './popup'
@@ -1,4 +1,4 @@
import type { Model } from '@renderer/types'
import type { AdaptedApiModel, Model } from '@renderer/types'
import type { ReactNode } from 'react'
/**
@@ -44,3 +44,17 @@ export type FlatListModel = FlatListBaseItem & {
* 扁平化列表项
*/
export type FlatListItem = FlatListGroup | FlatListModel
/**
* 模型列表项
*/
export type FlatListApiModel = FlatListBaseItem & {
type: 'model'
model: AdaptedApiModel
tags?: ReactNode
}
/**
* 扁平化列表项
*/
export type FlatListApiItem = FlatListGroup | FlatListApiModel
@@ -0,0 +1,502 @@
import {
Button,
cn,
Form,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItemProps,
SelectItem,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { getModelLogo } from '@renderer/config/models'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import {
AddAgentForm,
AgentConfigurationSchema,
AgentEntity,
AgentType,
BaseAgentForm,
isAgentType,
PermissionMode,
Tool,
UpdateAgentForm
} from '@renderer/types'
import { AlertTriangleIcon } from 'lucide-react'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ErrorBoundary } from '../../ErrorBoundary'
import { BaseOption, ModelOption, Option, renderOption } from './shared'
const logger = loggerService.withContext('AddAgentPopup')
interface AgentTypeOption extends BaseOption {
type: 'type'
key: AgentEntity['type']
name: AgentEntity['name']
}
type Option = AgentTypeOption | ModelOption
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? 'claude-4-sonnet',
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : [],
mcps: existing?.mcps ? [...existing.mcps] : [],
configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {})
})
interface BaseProps {
agent?: AgentWithTools
}
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 an agent.
*
* Either trigger or isOpen and onClose is given.
* @param agent - Optional agent 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 AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, onClose: _onClose }) => {
const { isOpen, onClose, onOpen } = useDisclosure({ isOpen: _isOpen, onClose: _onClose })
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { addAgent } = useAgents()
const { updateAgent } = useUpdateAgent()
// hard-coded. We only support anthropic for now.
const { models } = useApiModels({ providerType: 'anthropic' })
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
useEffect(() => {
if (isOpen) {
setForm(buildAgentForm(agent))
}
}, [agent, isOpen])
const selectedPermissionMode = form.configuration?.permission_mode ?? 'default'
const onPermissionModeChange = useCallback((keys: Selection) => {
if (keys === 'all') {
return
}
const [first] = Array.from(keys)
if (!first) {
return
}
setForm((prev) => {
const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {})
const nextMode = first as PermissionMode
if (parsedConfiguration.permission_mode === nextMode) {
if (!prev.configuration) {
return {
...prev,
configuration: parsedConfiguration
}
}
return prev
}
return {
...prev,
configuration: {
...parsedConfiguration,
permission_mode: nextMode
}
}
})
}, [])
// add supported agents type here.
const agentConfig = useMemo(
() =>
[
{
type: 'type',
key: 'claude-code',
label: 'Claude Code',
name: 'Claude Code',
avatar: ClaudeIcon
}
] as const satisfies AgentTypeOption[],
[]
)
const agentOptions: AgentTypeOption[] = useMemo(
() =>
agentConfig.map(
(option) =>
({
...option,
rendered: <Option option={option} />
}) as const satisfies SelectedItemProps
),
[agentConfig]
)
const onAgentTypeChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const prevConfig = agentConfig.find((config) => config.key === form.type)
let newName: string | undefined = form.name
if (prevConfig && prevConfig.name === form.name) {
const newConfig = agentConfig.find((config) => config.key === e.target.value)
if (newConfig) {
newName = newConfig.name
}
}
setForm((prev) => ({
...prev,
type: e.target.value as AgentType,
name: newName
}))
},
[agentConfig, form.name, form.type]
)
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 addAccessiblePath = useCallback(async () => {
try {
const selected = await window.api.file.selectFolder()
if (!selected) {
return
}
setForm((prev) => {
if (prev.accessible_paths.includes(selected)) {
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
return prev
}
return {
...prev,
accessible_paths: [...prev.accessible_paths, selected]
}
})
} catch (error) {
logger.error('Failed to select accessible path:', error as Error)
window.toast.error(t('agent.session.accessible_paths.select_failed'))
}
}, [t])
const removeAccessiblePath = useCallback((path: string) => {
setForm((prev) => ({
...prev,
accessible_paths: prev.accessible_paths.filter((item) => item !== path)
}))
}, [])
const modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? []).map((model) => ({
type: 'model',
key: model.id,
label: model.name,
avatar: getModelLogo(model.id),
providerId: model.provider,
providerName: model.provider_name
})) satisfies ModelOption[]
}, [models])
const onModelChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
setForm((prev) => ({
...prev,
model: e.target.value
}))
}, [])
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 (!isAgentType(form.type)) {
window.toast.error(t('agent.add.error.invalid_agent'))
loadingRef.current = false
return
}
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
}
if (isEditing(agent)) {
if (!agent) {
loadingRef.current = false
throw new Error('Agent is required for editing mode')
}
const updatePayload = {
id: agent.id,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths],
allowed_tools: [...form.allowed_tools],
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies UpdateAgentForm
updateAgent(updatePayload)
logger.debug('Updated agent', updatePayload)
} else {
const newAgent = {
type: form.type,
name: form.name,
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths],
allowed_tools: [...form.allowed_tools],
configuration: form.configuration ? { ...form.configuration } : undefined
} satisfies AddAgentForm
const result = await addAgent(newAgent)
if (!result.success) {
loadingRef.current = false
throw result.error
}
}
loadingRef.current = false
// setTimeoutTimer('onCreateAgent', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0)
onClose()
},
[
form.type,
form.model,
form.name,
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
form.configuration,
agent,
onClose,
t,
updateAgent,
addAgent
]
)
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}
classNames={{
base: 'max-h-[90vh]',
wrapper: 'overflow-hidden'
}}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>{isEditing(agent) ? t('agent.edit.title') : t('agent.add.title')}</ModalHeader>
<Form onSubmit={onSubmit} className="min-h-0 w-full shrink overflow-auto">
<ModalBody className="min-h-0 w-full flex-1 shrink overflow-auto">
<div className="flex gap-2">
<Select
isRequired
isDisabled={isEditing(agent)}
selectionMode="single"
selectedKeys={[form.type]}
disallowEmptySelection
onChange={onAgentTypeChange}
items={agentOptions}
label={t('agent.type.label')}
placeholder={t('agent.add.type.placeholder')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Input isRequired value={form.name} onValueChange={onNameChange} label={t('common.name')} />
</div>
<Select
isRequired
selectionMode="single"
selectedKeys={form.model ? [form.model] : []}
disallowEmptySelection
onChange={onModelChange}
items={modelOptions}
label={t('common.model')}
placeholder={t('common.placeholders.select.model')}
renderValue={renderOption}>
{(option) => (
<SelectItem key={option.key} textValue={option.label}>
<Option option={option} />
</SelectItem>
)}
</Select>
<Select
isRequired
selectionMode="single"
selectedKeys={[selectedPermissionMode]}
onSelectionChange={onPermissionModeChange}
label={t('agent.settings.tooling.permissionMode.title', 'Permission mode')}
placeholder={t('agent.settings.tooling.permissionMode.placeholder', 'Select permission mode')}
description={t(
'agent.settings.tooling.permissionMode.helper',
'Choose how the agent handles tool approvals.'
)}
items={permissionModeCards}>
{(item) => (
<SelectItem key={item.mode} textValue={t(item.titleKey, item.titleFallback)}>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{t(item.titleKey, item.titleFallback)}</span>
<span className="text-foreground-500 text-xs">
{t(item.descriptionKey, item.descriptionFallback)}
</span>
<span className="text-foreground-400 text-xs">
{t(item.behaviorKey, item.behaviorFallback)}
</span>
{item.caution ? (
<span className="flex items-center gap-1 text-danger-500 text-xs">
<AlertTriangleIcon size={12} className="text-danger" />
{t(
'agent.settings.tooling.permissionMode.bypassPermissions.warning',
'Use with caution — all tools will run without asking for approval.'
)}
</span>
) : null}
</div>
</SelectItem>
)}
</Select>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground text-sm">
{t('agent.session.accessible_paths.label')}
</span>
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
{t('agent.session.accessible_paths.add')}
</Button>
</div>
{form.accessible_paths.length > 0 ? (
<div className="space-y-2">
{form.accessible_paths.map((path) => (
<div
key={path}
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
<span className="truncate text-sm" title={path}>
{path}
</span>
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
{t('common.delete')}
</Button>
</div>
))}
</div>
) : (
<p className="text-foreground-400 text-sm">{t('agent.session.accessible_paths.empty')}</p>
)}
</div>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
<Textarea
label={t('common.description')}
value={form.description ?? ''}
onValueChange={onDescChange}
/>
</ModalBody>
<ModalFooter className="w-full">
<Button onPress={onClose}>{t('common.close')}</Button>
<Button color="primary" type="submit" isLoading={loadingRef.current}>
{isEditing(agent) ? t('common.confirm') : t('common.add')}
</Button>
</ModalFooter>
</Form>
</>
)}
</ModalContent>
</Modal>
</ErrorBoundary>
)
}
@@ -0,0 +1,319 @@
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 {
AgentEntity,
AgentSessionEntity,
BaseSessionForm,
CreateSessionForm,
Tool,
UpdateSessionForm
} from '@renderer/types'
import { FormEvent, ReactNode, 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>
)
}
@@ -0,0 +1,51 @@
import { Avatar, SelectedItemProps, SelectedItems } from '@heroui/react'
import { getProviderLabel } from '@renderer/i18n/label'
import { useTranslation } from 'react-i18next'
export interface BaseOption {
type: 'type' | 'model'
key: string
label: string
// img src
avatar: string
}
export interface ModelOption extends BaseOption {
providerId?: string
providerName?: string
}
export function isModelOption(option: BaseOption): option is ModelOption {
return option.type === 'model'
}
export const Item = ({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />
export const renderOption = (items: SelectedItems<BaseOption>) =>
items.map((item) => <Item key={item.key} item={item} />)
export const Option = ({ option }: { option?: BaseOption | null }) => {
const { t } = useTranslation()
if (!option) {
return (
<div className="flex gap-2">
<Avatar name="?" className="h-5 w-5" />
{t('common.invalid_value')}
</div>
)
}
const providerLabel = (() => {
if (!isModelOption(option)) return null
if (option.providerName) return option.providerName
if (option.providerId) return getProviderLabel(option.providerId)
return null
})()
return (
<div className="flex gap-2">
<Avatar src={option.avatar} className="h-5 w-5" />
<span className="truncate">{option.label}</span>
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
</div>
)
}
+1 -1
View File
@@ -36,7 +36,7 @@ interface MultipleSelectorProps<V> extends BaseSelectorProps<V> {
onChange: (value: V[]) => void
}
type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
export type SelectorProps<V> = SingleSelectorProps<V> | MultipleSelectorProps<V>
const Selector = <V extends string | number>({
options,
@@ -87,7 +87,7 @@ const getTabIcon = (
switch (tabId) {
case 'home':
return <Home size={14} />
case 'agents':
case 'store':
return <Sparkle size={14} />
case 'translate':
return <Languages size={14} />
@@ -0,0 +1,54 @@
import { Chip, cn, Select, SelectedItems, SelectItem, SelectProps } from '@heroui/react'
import { 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>
)
}
@@ -0,0 +1 @@
export { AllowedToolsSelect } from './AllowedToolsSelect'
+2 -2
View File
@@ -133,7 +133,7 @@ const MainMenus: FC = () => {
const iconMap = {
assistants: <MessageSquare size={18} className="icon" />,
agents: <Sparkle size={18} className="icon" />,
store: <Sparkle size={18} className="icon" />,
paintings: <Palette size={18} className="icon" />,
translate: <Languages size={18} className="icon" />,
minapp: <LayoutGrid size={18} className="icon" />,
@@ -145,7 +145,7 @@ const MainMenus: FC = () => {
const pathMap = {
assistants: '/',
agents: '/agents',
store: '/store',
paintings: `/paintings/${defaultPaintingProvider}`,
translate: '/translate',
minapp: '/apps',
+21
View File
@@ -0,0 +1,21 @@
import ClaudeAvatar from '@renderer/assets/images/models/claude.png'
import { AgentBase, AgentType } from '@renderer/types'
// base agent config. no default config for now.
const DEFAULT_AGENT_CONFIG: Omit<AgentBase, 'model'> = {
accessible_paths: []
} as const
// no default config for now.
export const DEFAULT_CLAUDE_CODE_CONFIG: Omit<AgentBase, 'model'> = {
...DEFAULT_AGENT_CONFIG
} as const
export const getAgentTypeAvatar = (type: AgentType): string => {
switch (type) {
case 'claude-code':
return ClaudeAvatar
default:
return ''
}
}
+10 -1
View File
@@ -55,7 +55,7 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png
import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import type { AtLeast, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types'
import type { AtLeast, Model, Provider, ProviderType, SystemProvider, SystemProviderId } from '@renderer/types'
import { isSystemProvider, OpenAIServiceTiers } from '@renderer/types'
import { TOKENFLUX_HOST } from './constant'
@@ -67,6 +67,7 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
type: 'openai',
apiKey: '',
apiHost: 'https://api.cherry-ai.com/',
anthropicApiHost: 'https://api.cherry-ai.com',
models: [glm45FlashModel, qwen38bModel],
isSystem: true,
enabled: true
@@ -99,6 +100,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://aihubmix.com',
anthropicApiHost: 'https://aihubmix.com/anthropic',
isAnthropicModel: (m: Model) => m.id.includes('claude'),
models: SYSTEM_MODELS.aihubmix,
isSystem: true,
enabled: false
@@ -129,6 +132,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://open.bigmodel.cn/api/paas/v4/',
anthropicApiHost: 'https://open.bigmodel.cn/api/anthropic',
models: SYSTEM_MODELS.zhipu,
isSystem: true,
enabled: false
@@ -139,6 +143,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.deepseek.com',
anthropicApiHost: 'https://api.deepseek.com/anthropic',
models: SYSTEM_MODELS.deepseek,
isSystem: true,
enabled: false
@@ -279,6 +284,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'http://localhost:3000',
anthropicApiHost: 'http://localhost:3000',
models: SYSTEM_MODELS['new-api'],
isSystem: true,
enabled: false
@@ -384,6 +390,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api.moonshot.cn',
anthropicApiHost: 'https://api.moonshot.cn/anthropic',
models: SYSTEM_MODELS.moonshot,
isSystem: true,
enabled: false
@@ -404,6 +411,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/',
anthropicApiHost: 'https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy',
models: SYSTEM_MODELS.dashscope,
isSystem: true,
enabled: false
@@ -544,6 +552,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'openai',
apiKey: '',
apiHost: 'https://api-inference.modelscope.cn/v1/',
anthropicApiHost: 'https://api-inference.modelscope.cn',
models: SYSTEM_MODELS.modelscope,
isSystem: true,
enabled: false
@@ -0,0 +1,66 @@
import { TopicType } from '@renderer/types'
export type MessageMenubarScope = TopicType
export type MessageMenubarButtonId =
| 'user-regenerate'
| 'user-edit'
| 'copy'
| 'assistant-regenerate'
| 'assistant-mention-model'
| 'translate'
| 'useful'
| 'notes'
| 'delete'
| 'trace'
| 'more-menu'
export type MessageMenubarScopeConfig = {
buttonIds: MessageMenubarButtonId[]
dropdownRootAllowKeys?: string[]
}
export const DEFAULT_MESSAGE_MENUBAR_SCOPE: MessageMenubarScope = TopicType.Chat
export const DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = [
'user-regenerate',
'user-edit',
'copy',
'assistant-regenerate',
'assistant-mention-model',
'translate',
'useful',
'notes',
'delete',
'trace',
'more-menu'
]
export const SESSION_MESSAGE_MENUBAR_BUTTON_IDS: MessageMenubarButtonId[] = [
'copy',
'translate',
'notes',
'delete',
'more-menu'
]
const messageMenubarRegistry = new Map<MessageMenubarScope, MessageMenubarScopeConfig>([
[DEFAULT_MESSAGE_MENUBAR_SCOPE, { buttonIds: [...DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS] }],
[TopicType.Chat, { buttonIds: [...DEFAULT_MESSAGE_MENUBAR_BUTTON_IDS] }],
[TopicType.Session, { buttonIds: [...SESSION_MESSAGE_MENUBAR_BUTTON_IDS], dropdownRootAllowKeys: ['save', 'export'] }]
])
export const registerMessageMenubarConfig = (scope: MessageMenubarScope, config: MessageMenubarScopeConfig) => {
const clonedConfig: MessageMenubarScopeConfig = {
buttonIds: [...config.buttonIds],
dropdownRootAllowKeys: config.dropdownRootAllowKeys ? [...config.dropdownRootAllowKeys] : undefined
}
messageMenubarRegistry.set(scope, clonedConfig)
}
export const getMessageMenubarConfig = (scope: MessageMenubarScope): MessageMenubarScopeConfig => {
if (messageMenubarRegistry.has(scope)) {
return messageMenubarRegistry.get(scope) as MessageMenubarScopeConfig
}
return messageMenubarRegistry.get(DEFAULT_MESSAGE_MENUBAR_SCOPE) as MessageMenubarScopeConfig
}
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SidebarIcon } from '@shared/data/preference/preferenceTypes'
*/
// export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [
// 'assistants',
// 'agents',
// 'store',
// 'paintings',
// 'translate',
// 'minapp',
@@ -0,0 +1,53 @@
import { PermissionMode } from '@renderer/types'
export type PermissionModeCard = {
mode: PermissionMode
titleKey: string
titleFallback: string
descriptionKey: string
descriptionFallback: string
behaviorKey: string
behaviorFallback: string
caution?: boolean
unsupported?: boolean
}
export const permissionModeCards: PermissionModeCard[] = [
{
mode: 'default',
titleKey: 'agent.settings.tooling.permissionMode.default.title',
titleFallback: 'Default (ask before continuing)',
descriptionKey: 'agent.settings.tooling.permissionMode.default.description',
descriptionFallback: 'Read-only tools are pre-approved; everything else still needs permission.',
behaviorKey: 'agent.settings.tooling.permissionMode.default.behavior',
behaviorFallback: 'Read-only tools are pre-approved automatically.'
},
{
mode: 'plan',
titleKey: 'agent.settings.tooling.permissionMode.plan.title',
titleFallback: 'Planning mode',
descriptionKey: 'agent.settings.tooling.permissionMode.plan.description',
descriptionFallback: 'Shares the default read-only tool set but presents a plan before execution.',
behaviorKey: 'agent.settings.tooling.permissionMode.plan.behavior',
behaviorFallback: 'Read-only defaults are pre-approved while execution remains disabled.'
},
{
mode: 'acceptEdits',
titleKey: 'agent.settings.tooling.permissionMode.acceptEdits.title',
titleFallback: 'Auto-accept file edits',
descriptionKey: 'agent.settings.tooling.permissionMode.acceptEdits.description',
descriptionFallback: 'File edits and filesystem operations are automatically approved.',
behaviorKey: 'agent.settings.tooling.permissionMode.acceptEdits.behavior',
behaviorFallback: 'Pre-approves trusted filesystem tools so edits run immediately.'
},
{
mode: 'bypassPermissions',
titleKey: 'agent.settings.tooling.permissionMode.bypassPermissions.title',
titleFallback: 'Bypass permission checks',
descriptionKey: 'agent.settings.tooling.permissionMode.bypassPermissions.description',
descriptionFallback: 'All permission prompts are skipped — use with caution.',
behaviorKey: 'agent.settings.tooling.permissionMode.bypassPermissions.behavior',
behaviorFallback: 'Every tool is pre-approved automatically.',
caution: true
}
]
+23
View File
@@ -0,0 +1,23 @@
import { useCallback } from 'react'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useAgent = (id: string | null) => {
const client = useAgentClient()
const key = id ? client.agentPaths.withId(id) : null
const fetcher = useCallback(async () => {
if (!id || id === 'fake') {
return null
}
const result = await client.getAgent(id)
return result
}, [client, id])
const { data, error, isLoading } = useSWR(key, id ? fetcher : null)
return {
agent: data,
error,
isLoading
}
}
@@ -0,0 +1,15 @@
import { AgentApiClient } from '@renderer/api/agent'
import { useSettings } from '../useSettings'
export const useAgentClient = () => {
const { apiServer } = useSettings()
const { host, port, apiKey } = apiServer
const client = new AgentApiClient({
baseURL: `http://${host}:${port}`,
headers: {
Authorization: `Bearer ${apiKey}`
}
})
return client
}
@@ -0,0 +1,80 @@
import { loggerService } from '@logger'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import { useCallback, useEffect } from 'react'
import { useAgentClient } from './useAgentClient'
const logger = loggerService.withContext('useAgentSessionInitializer')
/**
* Hook to automatically initialize and load the latest session for an agent
* when the agent is activated. This ensures that when switching to an agent,
* its most recent session is automatically selected.
*/
export const useAgentSessionInitializer = () => {
const dispatch = useAppDispatch()
const client = useAgentClient()
const { chat } = useRuntime()
const { activeAgentId, activeSessionId } = chat
/**
* Initialize session for the given agent by loading its sessions
* and setting the latest one as active
*/
const initializeAgentSession = useCallback(
async (agentId: string) => {
if (!agentId || agentId === 'fake') return
try {
// Check if this agent already has an active session
const currentSessionId = activeSessionId[agentId]
if (currentSessionId) {
// Session already exists, just switch to session view
dispatch(setActiveTopicOrSessionAction('session'))
return
}
// Load sessions for this agent
const response = await client.listSessions(agentId)
const sessions = response.data
if (sessions && sessions.length > 0) {
// Get the latest session (first in the list, assuming they're sorted by updatedAt)
const latestSession = sessions[0]
// Set the latest session as active
dispatch(setActiveSessionIdAction({ agentId, sessionId: latestSession.id }))
dispatch(setActiveTopicOrSessionAction('session'))
} else {
// No sessions exist, we might want to create one
// But for now, just switch to session view and let the Sessions component handle it
dispatch(setActiveTopicOrSessionAction('session'))
}
} catch (error) {
logger.error('Failed to initialize agent session:', error as Error)
// Even if loading fails, switch to session view
dispatch(setActiveTopicOrSessionAction('session'))
}
},
[client, dispatch, activeSessionId]
)
/**
* Auto-initialize when activeAgentId changes
*/
useEffect(() => {
if (activeAgentId && activeAgentId !== 'fake') {
// Check if we need to initialize this agent's session
const hasActiveSession = activeSessionId[activeAgentId]
if (!hasActiveSession) {
initializeAgentSession(activeAgentId)
}
}
}, [activeAgentId, activeSessionId, initializeAgentSession])
return {
initializeAgentSession
}
}
@@ -0,0 +1,94 @@
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId, setActiveSessionIdAction } from '@renderer/store/runtime'
import { AddAgentForm, CreateAgentResponse } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useRuntime } from '../useRuntime'
import { useAgentClient } from './useAgentClient'
type Result<T> =
| {
success: true
data: T
}
| {
success: false
error: Error
}
export const useAgents = () => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.agentPaths.base
const fetcher = useCallback(async () => {
const result = await client.listAgents()
// NOTE: We only use the array for now. useUpdateAgent depends on this behavior.
return result.data
}, [client])
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const { chat } = useRuntime()
const { activeAgentId } = chat
const dispatch = useAppDispatch()
const addAgent = useCallback(
async (form: AddAgentForm): Promise<Result<CreateAgentResponse>> => {
try {
const result = await client.createAgent(form)
mutate((prev) => [...(prev ?? []), result])
window.toast.success(t('common.add_success'))
return { success: true, data: result }
} catch (error) {
const errorMessage = formatErrorMessageWithPrefix(error, t('agent.add.error.failed'))
window.toast.error(errorMessage)
if (error instanceof Error) {
return { success: false, error }
} else {
return { success: false, error: new Error(formatErrorMessageWithPrefix(error, t('agent.add.error.failed'))) }
}
}
},
[client, mutate, t]
)
const deleteAgent = useCallback(
async (id: string) => {
try {
await client.deleteAgent(id)
dispatch(setActiveSessionIdAction({ agentId: id, sessionId: null }))
if (activeAgentId === id) {
const newId = data?.filter((a) => a.id !== id).find(() => true)?.id
if (newId) {
dispatch(setActiveAgentId(newId))
} else {
dispatch(setActiveAgentId(null))
}
}
mutate((prev) => prev?.filter((a) => a.id !== id) ?? [])
window.toast.success(t('common.delete_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.delete.error.failed')))
}
},
[activeAgentId, client, data, dispatch, mutate, t]
)
const getAgent = useCallback(
async (id: string) => {
const result = await client.getAgent(id)
mutate((prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
},
[client, mutate]
)
return {
agents: data ?? [],
error,
isLoading,
addAgent,
deleteAgent,
getAgent
}
}
+13
View File
@@ -0,0 +1,13 @@
import { ApiModelsFilter } from '@renderer/types'
import { useApiModels } from './useModels'
export type UseModelProps = {
id?: string
filter?: ApiModelsFilter
}
export const useApiModel = ({ id, filter }: UseModelProps) => {
const { models } = useApiModels(filter)
return models.find((model) => model.id === id)
}
@@ -0,0 +1,35 @@
import { ApiModel, ApiModelsFilter } from '@renderer/types'
import { merge } from 'lodash'
import { useCallback } from 'react'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useApiModels = (filter?: ApiModelsFilter) => {
const client = useAgentClient()
// const defaultFilter = { limit: -1 } satisfies ApiModelsFilter
const defaultFilter = {} satisfies ApiModelsFilter
const finalFilter = merge(filter, defaultFilter)
const path = client.getModelsPath(finalFilter)
const fetcher = useCallback(async () => {
const limit = finalFilter.limit || 100
let offset = finalFilter.offset || 0
const allModels: ApiModel[] = []
let total = Infinity
while (offset < total) {
const pageFilter = { ...finalFilter, limit, offset }
const res = await client.getModels(pageFilter)
allModels.push(...(res.data || []))
total = res.total ?? 0
offset += limit
}
return { data: allModels, total }
}, [client, finalFilter])
const { data, error, isLoading } = useSWR(path, fetcher)
return {
models: data?.data ?? [],
error,
isLoading
}
}
@@ -0,0 +1,54 @@
import { useAppDispatch } from '@renderer/store'
import { loadTopicMessagesThunk } from '@renderer/store/thunk/messageThunk'
import { UpdateSessionForm } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { useCallback, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useSession = (agentId: string, sessionId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).withId(sessionId)
const dispatch = useAppDispatch()
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
const fetcher = async () => {
const data = await client.getSession(agentId, sessionId)
return data
}
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
// Use loadTopicMessagesThunk to load messages (with caching mechanism)
// This ensures messages are preserved when switching between sessions/tabs
useEffect(() => {
if (sessionId) {
// loadTopicMessagesThunk will check if messages already exist in Redux
// and skip loading if they do (unless forceReload is true)
dispatch(loadTopicMessagesThunk(sessionTopicId))
}
}, [dispatch, sessionId, sessionTopicId])
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate(result)
} catch (error) {
window.toast.error(t('agent.session.update.error.failed'))
}
},
[agentId, client, mutate, t]
)
return {
session: data,
error,
isLoading,
updateSession,
mutate
}
}
@@ -0,0 +1,70 @@
import { CreateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useAgentClient } from './useAgentClient'
export const useSessions = (agentId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const key = client.getSessionPaths(agentId).base
const fetcher = async () => {
const data = await client.listSessions(agentId)
return data.data
}
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
const createSession = useCallback(
async (form: CreateSessionForm) => {
try {
const result = await client.createSession(agentId, form)
await mutate((prev) => [...(prev ?? []), result], { revalidate: false })
return result
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.create.error.failed')))
return undefined
}
},
[agentId, client, mutate, t]
)
// TODO: including messages field
const getSession = useCallback(
async (id: string) => {
try {
const result = await client.getSession(agentId, id)
mutate((prev) => prev?.map((session) => (session.id === result.id ? result : session)))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.get.error.failed')))
}
},
[agentId, client, mutate, t]
)
const deleteSession = useCallback(
async (id: string) => {
if (!agentId) return false
try {
await client.deleteSession(agentId, id)
mutate((prev) => prev?.filter((session) => session.id !== id))
return true
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
return false
}
},
[agentId, client, mutate, t]
)
return {
sessions: data ?? [],
error,
isLoading,
createSession,
getSession,
deleteSession
}
}
@@ -0,0 +1,45 @@
import { ListAgentsResponse, UpdateAgentForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { useAgentClient } from './useAgentClient'
export type UpdateAgentOptions = {
/** Whether to show success toast after updating. Defaults to true. */
showSuccessToast?: boolean
}
export const useUpdateAgent = () => {
const { t } = useTranslation()
const client = useAgentClient()
const listKey = client.agentPaths.base
const updateAgent = useCallback(
async (form: UpdateAgentForm, options?: UpdateAgentOptions) => {
try {
const itemKey = client.agentPaths.withId(form.id)
// may change to optimistic update
const result = await client.updateAgent(form)
mutate<ListAgentsResponse['data']>(listKey, (prev) => prev?.map((a) => (a.id === result.id ? result : a)) ?? [])
mutate(itemKey, result)
if (options?.showSuccessToast ?? true) {
window.toast.success(t('common.update_success'))
}
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[client, listKey, t]
)
const updateModel = useCallback(
async (agentId: string, modelId: string, options?: UpdateAgentOptions) => {
updateAgent({ id: agentId, model: modelId }, options)
},
[updateAgent]
)
return { updateAgent, updateModel }
}
@@ -0,0 +1,36 @@
import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
const sessionId = form.id
try {
const itemKey = paths.withId(sessionId)
// may change to optimistic update
const result = await client.updateSession(agentId, form)
mutate<ListAgentSessionsResponse['data']>(
listKey,
(prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? []
)
mutate(itemKey, result)
window.toast.success(t('common.update_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[agentId, client, listKey, paths, t]
)
return updateSession
}
-28
View File
@@ -1,28 +0,0 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { addAgent, removeAgent, updateAgent, updateAgents, updateAgentSettings } from '@renderer/store/agents'
import type { Agent, AssistantSettings } from '@renderer/types'
export function useAgents() {
const agents = useAppSelector((state) => state.agents.agents)
const dispatch = useAppDispatch()
return {
agents,
updateAgents: (agents: Agent[]) => dispatch(updateAgents(agents)),
addAgent: (agent: Agent) => dispatch(addAgent(agent)),
removeAgent: (id: string) => dispatch(removeAgent({ id }))
}
}
export function useAgent(id: string) {
const agent = useAppSelector((state) => state.agents.agents.find((a) => a.id === id) as Agent)
const dispatch = useAppDispatch()
return {
agent,
updateAgent: (agent: Agent) => dispatch(updateAgent(agent)),
updateAgentSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAgentSettings({ assistantId: agent.id, settings }))
}
}
}
@@ -0,0 +1,35 @@
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistantPreset,
removeAssistantPreset,
setAssistantPresets,
updateAssistantPreset,
updateAssistantPresetSettings
} from '@renderer/store/assistants'
import { AssistantPreset, AssistantSettings } from '@renderer/types'
export function useAssistantPresets() {
const presets = useAppSelector((state) => state.assistants.presets)
const dispatch = useAppDispatch()
return {
presets,
setAssistantPresets: (presets: AssistantPreset[]) => dispatch(setAssistantPresets(presets)),
addAssistantPreset: (preset: AssistantPreset) => dispatch(addAssistantPreset(preset)),
removeAssistantPreset: (id: string) => dispatch(removeAssistantPreset({ id }))
}
}
export function useAssistantPreset(id: string) {
// FIXME: undefined is not handled
const preset = useAppSelector((state) => state.assistants.presets.find((a) => a.id === id) as AssistantPreset)
const dispatch = useAppDispatch()
return {
preset,
updateAssistantPreset: (preset: AssistantPreset) => dispatch(updateAssistantPreset(preset)),
updateAssistantPresetSettings: (settings: Partial<AssistantSettings>) => {
dispatch(updateAssistantPresetSettings({ assistantId: preset.id, settings }))
}
}
}
+50 -26
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTimer } from './useTimer'
export interface UseInPlaceEditOptions {
onSave: (value: string) => void
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
onCancel?: () => void
autoSelectOnStart?: boolean
trimOnSave?: boolean
@@ -9,6 +11,7 @@ export interface UseInPlaceEditOptions {
export interface UseInPlaceEditReturn {
isEditing: boolean
isSaving: boolean
editValue: string
inputRef: React.RefObject<HTMLInputElement | null>
startEdit: (initialValue: string) => void
@@ -16,23 +19,27 @@ export interface UseInPlaceEditReturn {
cancelEdit: () => void
handleKeyDown: (e: React.KeyboardEvent) => void
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleValueChange: (value: string) => void
}
/**
* A React hook that provides in-place editing functionality for text inputs
* @param options - Configuration options for the in-place edit behavior
* @param options.onSave - Callback function called when edits are saved
* @param options.onCancel - Optional callback function called when editing is cancelled
* @param options.autoSelectOnStart - Whether to automatically select text when editing starts (default: true)
* @param options.trimOnSave - Whether to trim whitespace when saving (default: true)
* @returns An object containing the editing state and handler functions
*/
export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditReturn {
const { onSave, onCancel, autoSelectOnStart = true, trimOnSave = true } = options
const [isSaving, setIsSaving] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const editTimerRef = useRef<NodeJS.Timeout>(undefined)
useEffect(() => {
return () => {
clearTimeout(editTimerRef.current)
}
}, [])
const { setTimeoutTimer } = useTimer()
const startEdit = useCallback(
(initialValue: string) => {
@@ -40,26 +47,37 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(initialValue)
setOriginalValue(initialValue)
clearTimeout(editTimerRef.current)
editTimerRef.current = setTimeout(() => {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
}, 0)
setTimeoutTimer(
'startEdit',
() => {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
},
0
)
},
[autoSelectOnStart]
[autoSelectOnStart, setTimeoutTimer]
)
const saveEdit = useCallback(() => {
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
onSave(finalValue)
const saveEdit = useCallback(async () => {
if (isSaving) return
setIsSaving(true)
try {
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
await onSave(finalValue)
}
setIsEditing(false)
setEditValue('')
setOriginalValue('')
} finally {
setIsSaving(false)
}
setIsEditing(false)
setEditValue('')
setOriginalValue('')
}, [editValue, originalValue, onSave, trimOnSave])
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
const cancelEdit = useCallback(() => {
setIsEditing(false)
@@ -86,6 +104,10 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(e.target.value)
}, [])
const handleValueChange = useCallback((value: string) => {
setEditValue(value)
}, [])
// Handle clicks outside the input to save
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -105,12 +127,14 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
return {
isEditing,
isSaving,
editValue,
inputRef,
startEdit,
saveEdit,
cancelEdit,
handleKeyDown,
handleInputChange
handleInputChange,
handleValueChange
}
}
+4 -4
View File
@@ -25,8 +25,8 @@ import { cloneDeep } from 'lodash'
import { useCallback, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useAgents } from './useAgents'
import { useAssistants } from './useAssistant'
import { useAssistantPresets } from './useAssistantPresets'
import { useTimer } from './useTimer'
export const useKnowledge = (baseId: string) => {
@@ -340,7 +340,7 @@ export const useKnowledgeBases = () => {
const dispatch = useDispatch()
const bases = useSelector((state: RootState) => state.knowledge.bases)
const { assistants, updateAssistants } = useAssistants()
const { agents, updateAgents } = useAgents()
const { presets, setAssistantPresets } = useAssistantPresets()
const addKnowledgeBase = (base: KnowledgeBase) => {
dispatch(addBase(base))
@@ -367,7 +367,7 @@ export const useKnowledgeBases = () => {
})
// remove agent knowledge_base
const _agents = agents.map((agent) => {
const _presets = presets.map((agent) => {
if (agent.knowledge_bases?.find((kb) => kb.id === baseId)) {
return {
...agent,
@@ -378,7 +378,7 @@ export const useKnowledgeBases = () => {
})
updateAssistants(_assistants)
updateAgents(_agents)
setAssistantPresets(_presets)
}
const updateKnowledgeBases = (bases: KnowledgeBase[]) => {
+14 -12
View File
@@ -2,30 +2,32 @@ import { cacheService } from '@data/CacheService'
import { throttle } from 'lodash'
import { useEffect, useRef } from 'react'
export default function useScrollPosition(key: string) {
import { useTimer } from './useTimer'
/**
* A custom hook that manages scroll position persistence for a container element
* @param key - A unique identifier used to store/retrieve the scroll position
* @returns An object containing:
* - containerRef: React ref for the scrollable container
* - handleScroll: Throttled scroll event handler that saves scroll position
*/
export default function useScrollPosition(key: string, throttleWait?: number) {
const containerRef = useRef<HTMLDivElement>(null)
const scrollKey = `scroll:${key}`
const scrollTimerRef = useRef<NodeJS.Timeout>(undefined)
const { setTimeoutTimer } = useTimer()
const handleScroll = throttle(() => {
const position = containerRef.current?.scrollTop ?? 0
window.requestAnimationFrame(() => {
cacheService.set(scrollKey, position)
})
}, 100)
}, throttleWait ?? 100)
useEffect(() => {
const scroll = () => containerRef.current?.scrollTo({ top: cacheService.get(scrollKey) || 0 })
scroll()
clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = setTimeout(scroll, 50)
}, [scrollKey])
useEffect(() => {
return () => {
clearTimeout(scrollTimerRef.current)
}
}, [])
setTimeoutTimer('scrollEffect', scroll, 50)
}, [scrollKey, setTimeoutTimer])
return { containerRef, handleScroll }
}
+11
View File
@@ -17,6 +17,17 @@ export default function useUserTheme() {
// overwrite hero UI primary color.
document.body.style.setProperty('--primary', colorPrimary.toString())
document.body.style.setProperty('--primary-foreground', getForegroundColor(colorPrimary.hex()))
document.body.style.setProperty('--heroui-primary', colorPrimary.toString())
document.body.style.setProperty('--heroui-primary-900', colorPrimary.lighten(0.5).toString())
document.body.style.setProperty('--heroui-primary-800', colorPrimary.lighten(0.4).toString())
document.body.style.setProperty('--heroui-primary-700', colorPrimary.lighten(0.3).toString())
document.body.style.setProperty('--heroui-primary-600', colorPrimary.lighten(0.2).toString())
document.body.style.setProperty('--heroui-primary-500', colorPrimary.lighten(0.1).toString())
document.body.style.setProperty('--heroui-primary-400', colorPrimary.toString())
document.body.style.setProperty('--heroui-primary-300', colorPrimary.darken(0.1).toString())
document.body.style.setProperty('--heroui-primary-200', colorPrimary.darken(0.2).toString())
document.body.style.setProperty('--heroui-primary-100', colorPrimary.darken(0.3).toString())
document.body.style.setProperty('--heroui-primary-50', colorPrimary.darken(0.4).toString())
document.body.style.setProperty('--color-primary-soft', colorPrimary.alpha(0.6).toString())
document.body.style.setProperty('--color-primary-mute', colorPrimary.alpha(0.3).toString())
+12 -2
View File
@@ -5,7 +5,7 @@
*/
import { loggerService } from '@logger'
import type { BuiltinMCPServerName, BuiltinOcrProviderId, ThinkingOption } from '@renderer/types'
import type { AgentType, BuiltinMCPServerName, BuiltinOcrProviderId, ThinkingOption } from '@renderer/types'
import { BuiltinMCPServerNames } from '@renderer/types'
import i18n from './index'
@@ -128,7 +128,8 @@ export const getRestoreProgressLabel = (key: string): string => {
}
const titleKeyMap = {
agents: 'title.agents',
// TODO: update i18n key
store: 'title.store',
apps: 'title.apps',
code: 'title.code',
files: 'title.files',
@@ -341,3 +342,12 @@ export const getBuiltinOcrProviderLabel = (key: BuiltinOcrProviderId) => {
else if (key == 'paddleocr') return 'PaddleOCR'
else return getLabel(builtinOcrProviderKeyMap, key)
}
export const getAgentTypeLabel = (key: AgentType) => {
switch (key) {
case 'claude-code':
return 'Claude Code'
default:
return 'Unknown Type'
}
}
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Add to Assistant",
"knowledge_base": {
"label": "Knowledge Base",
"placeholder": "Select Knowledge Base"
"error": {
"failed": "Failed to add a agent",
"invalid_agent": "Invalid Agent"
},
"name": {
"label": "Name",
"placeholder": "Enter name"
},
"prompt": {
"label": "Prompt",
"placeholder": "Enter prompt",
"variables": {
"tip": {
"content": "{{date}}:\tDate\n{{time}}:\tTime\n{{datetime}}:\tDate and time\n{{system}}:\tOperating system\n{{arch}}:\tCPU architecture\n{{language}}:\tLanguage\n{{model_name}}:\tModel name\n{{username}}:\tUsername",
"title": "Available variables"
}
}
},
"title": "Create Agent",
"unsaved_changes_warning": "You have unsaved changes. Are you sure you want to close?"
},
"delete": {
"popup": {
"content": "Are you sure you want to delete this agent?"
"title": "Add Agent",
"type": {
"placeholder": "Select an agent type"
}
},
"edit": {
"model": {
"select": {
"title": "Select Model"
}
"delete": {
"content": "Deleting the agent will forcibly stop and delete all sessions associated with the agent. Are you sure?",
"error": {
"failed": "Failed to delete the agent"
},
"title": "Delete Agent"
},
"edit": {
"title": "Edit Agent"
},
"export": {
"agent": "Export Agent"
},
"import": {
"button": "Import",
"get": {
"error": {
"fetch_failed": "Failed to fetch from URL",
"invalid_format": "Invalid agent format: missing required fields",
"url_required": "Please enter a URL"
},
"file_filter": "JSON Files",
"select_file": "Select File",
"title": "Import from External",
"type": {
"file": "File",
"url": "URL"
},
"url_placeholder": "Enter JSON URL"
"failed": "Failed to get the agent."
}
},
"manage": {
"title": "Manage Agents"
"list": {
"error": {
"failed": "Failed to list agents."
}
},
"my_agents": "My Agents",
"search": {
"no_results": "No results found"
"session": {
"accessible_paths": {
"add": "Add directory",
"duplicate": "This directory is already included.",
"empty": "Select at least one directory that the agent can access.",
"error": {
"at_least_one": "Please select at least one accessible directory."
},
"label": "Accessible directories",
"select_failed": "Failed to select directory."
},
"add": {
"title": "Add a session"
},
"allowed_tools": {
"empty": "No tools available for this agent.",
"helper": "Pre-approved tools run without manual approval. Unselected tools require approval before use.",
"label": "Pre-approved tools",
"placeholder": "Select pre-approved tools"
},
"create": {
"error": {
"failed": "Failed to add a session"
}
},
"delete": {
"content": "Are you sure to delete this session?",
"error": {
"failed": "Failed to delete the session",
"last": "At least one session must be kept"
},
"title": "Delete session"
},
"edit": {
"title": "Edit session"
},
"get": {
"error": {
"failed": "Failed to get the session"
}
},
"label_one": "Session",
"label_other": "Sessions",
"update": {
"error": {
"failed": "Failed to update the session"
}
}
},
"settings": {
"title": "Agent Setting"
"advance": {
"maxTurns": {
"description": "Define how many request/response cycles the agent may complete automatically.",
"helper": "Higher values enable longer autonomous runs; lower values keep sessions short.",
"label": "Conversation turn limit"
},
"permissionMode": {
"description": "Control how the agent handles actions that require approval.",
"label": "Permission mode",
"options": {
"acceptEdits": "Accept edits automatically",
"bypassPermissions": "Bypass permission checks",
"default": "Default (ask before continuing)",
"plan": "Planning mode (requires plan approval)"
},
"placeholder": "Choose a permission behavior"
},
"title": "Advanced Settings"
},
"essential": "Essential Settings",
"prompt": "Prompt Settings",
"tooling": {
"mcp": {
"description": "Connect MCP servers to unlock additional tools you can approve above.",
"empty": "No MCP servers detected. Add one from the MCP settings page.",
"manageHint": "Need advanced configuration? Visit Settings → MCP Servers.",
"toggle": "Toggle {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Pre-approves trusted filesystem tools so edits run immediately.",
"description": "File edits and filesystem operations are automatically approved.",
"title": "Auto-accept file edits"
},
"bypassPermissions": {
"behavior": "Every tool is pre-approved automatically.",
"description": "All permission prompts are skipped — use with caution.",
"title": "Bypass permission checks",
"warning": "Use with caution — all tools will run without asking for approval."
},
"confirmChange": {
"description": "Switching modes updates the automatically approved tools.",
"title": "Change permission mode?"
},
"default": {
"behavior": "Read-only tools are pre-approved automatically.",
"description": "Read-only tools are pre-approved; everything else still needs permission.",
"title": "Default (ask before continuing)"
},
"helper": "Choose how the agent handles tool approvals.",
"placeholder": "Select permission mode",
"plan": {
"behavior": "Read-only defaults are pre-approved while execution remains disabled.",
"description": "Shares the default read-only tool set but presents a plan before execution.",
"title": "Planning mode"
},
"title": "Permission mode"
},
"preapproved": {
"autoBadge": "Added by mode",
"autoDescription": "This tool is auto-approved by the current permission mode.",
"empty": "No tools match your filters.",
"mcpBadge": "MCP tool",
"requiresApproval": "Requires approval when disabled",
"search": "Search tools",
"toggle": "Toggle {{name}}",
"warning": {
"description": "Enable only tools you trust. Mode defaults are highlighted automatically.",
"title": "Pre-approved tools run without manual review."
}
},
"review": {
"autoTools": "Auto: {{count}}",
"customTools": "Custom: {{count}}",
"helper": "Changes save automatically. Adjust the steps above any time to fine-tune permissions.",
"mcp": "MCP: {{count}}",
"mode": "Mode: {{mode}}"
},
"steps": {
"mcp": {
"title": "MCP servers"
},
"permissionMode": {
"title": "Step 1 · Permission mode"
},
"preapproved": {
"title": "Step 2 · Pre-approved tools"
},
"review": {
"title": "Step 3 · Review"
}
},
"tab": "Tooling & permissions"
},
"tools": {
"approved": "approved",
"caution": "Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "Choose which tools can run without manual approval.",
"requiresPermission": "Requires permission when not pre-approved.",
"tab": "Pre-approved tools",
"title": "Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Sorting"
"type": {
"label": "Agent Type",
"unknown": "Unknown Type"
},
"tag": {
"agent": "Agent",
"default": "Default",
"new": "New",
"system": "System"
"update": {
"error": {
"failed": "Failed to update the agent"
}
},
"title": "Agents"
"warning": {
"enable_server": "Enable API Server to use agents."
}
},
"apiServer": {
"actions": {
@@ -155,9 +276,86 @@
"showByList": "List View",
"showByTags": "Tag View"
},
"presets": {
"add": {
"button": "Add to Assistant",
"knowledge_base": {
"label": "Knowledge Base",
"placeholder": "Select Knowledge Base"
},
"name": {
"label": "Name",
"placeholder": "Enter name"
},
"prompt": {
"label": "Prompt",
"placeholder": "Enter prompt",
"variables": {
"tip": {
"content": "{{date}}:\tDate\n{{time}}:\tTime\n{{datetime}}:\tDate and time\n{{system}}:\tOperating system\n{{arch}}:\tCPU architecture\n{{language}}:\tLanguage\n{{model_name}}:\tModel name\n{{username}}:\tUsername",
"title": "Available variables"
}
}
},
"title": "Create Assistant",
"unsaved_changes_warning": "You have unsaved changes. Are you sure you want to close?"
},
"delete": {
"popup": {
"content": "Are you sure you want to delete this assistant?"
}
},
"edit": {
"model": {
"select": {
"title": "Select Model"
}
},
"title": "Edit Assistant"
},
"export": {
"agent": "Export Assistant"
},
"import": {
"button": "Import",
"error": {
"fetch_failed": "Failed to fetch from URL",
"invalid_format": "Invalid assistant format: missing required fields",
"url_required": "Please enter a URL"
},
"file_filter": "JSON Files",
"select_file": "Select File",
"title": "Import from External",
"type": {
"file": "File",
"url": "URL"
},
"url_placeholder": "Enter JSON URL"
},
"manage": {
"title": "Manage Assistants"
},
"my_agents": "My Assistants",
"search": {
"no_results": "No results found"
},
"settings": {
"title": "Assistant Setting"
},
"sorting": {
"title": "Sorting"
},
"tag": {
"agent": "Assistant",
"default": "Default",
"new": "New",
"system": "System"
},
"title": "Assistants Library"
},
"save": {
"success": "Saved successfully",
"title": "Save to agent"
"title": "Save to assistant library"
},
"search": "Search assistants...",
"settings": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Add",
"add_success": "Added successfully",
"advanced_settings": "Advanced Settings",
"agent_one": "Agent",
"agent_other": "Agents",
"and": "and",
"assistant": "Assistant",
"assistant": "Agent",
"assistant_one": "Assistant",
"assistant_other": "Assistants",
"avatar": "Avatar",
"back": "Back",
"browse": "Browse",
@@ -765,6 +968,8 @@
"default": "Default",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete?",
"delete_failed": "Failed to delete",
"delete_success": "Deleted successfully",
"description": "Description",
"detail": "Detail",
"disabled": "Disabled",
@@ -774,6 +979,10 @@
"edit": "Edit",
"enabled": "Enabled",
"error": "error",
"errors": {
"create_message": "Failed to create message",
"validation": "Verification failed"
},
"expand": "Expand",
"file": {
"not_supported": "Unsupported file type {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Go to settings",
"i_know": "I know",
"inspect": "Inspect",
"invalid_value": "Invalid Value",
"knowledge_base": "Knowledge Base",
"language": "Language",
"loading": "Loading...",
@@ -795,6 +1005,11 @@
"none": "None",
"open": "Open",
"paste": "Paste",
"placeholders": {
"select": {
"model": "Select a model"
}
},
"preview": "Preview",
"prompt": "Prompt",
"provider": "Provider",
@@ -807,6 +1022,7 @@
"saved": "Saved",
"search": "Search",
"select": "Select",
"selected": "Selected",
"selectedItems": "Selected {{count}} items",
"selectedMessages": "Selected {{count}} messages",
"settings": "Settings",
@@ -821,6 +1037,9 @@
"success": "Success",
"swap": "Swap",
"topics": "Topics",
"unknown": "Unknown",
"unnamed": "Unnamed",
"update_success": "Update successfully",
"upload_files": "Upload file",
"warning": "Warning",
"you": "You"
@@ -893,6 +1112,7 @@
"modelType": "Model Type",
"name": "Error name",
"no_api_key": "API key is not configured",
"no_response": "No response",
"originalError": "Original Error",
"originalMessage": "Original Message",
"parameter": "Parameter",
@@ -958,6 +1178,9 @@
},
"document": "Document",
"edit": "Edit",
"error": {
"open_path": "Failed to open path {{path}}"
},
"file": "File",
"image": "Image",
"name": "Name",
@@ -3795,6 +4018,10 @@
"start_auth": "Start authorization",
"submit_code": "Complete login"
},
"anthropic_api_host": "Anthropic API Host",
"anthropic_api_host_preview": "Anthropic preview: {{url}}",
"anthropic_api_host_tip": "Only configure this when your provider exposes an Anthropic-compatible endpoint. Ending with / ignores v1, ending with # forces use of input address.",
"anthropic_api_host_tooltip": "Use only when the provider offers a Claude-compatible base URL.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "API Host",
"api_host_preview": "Preview: {{url}}",
"api_host_tooltip": "Override only when your provider requires a custom OpenAI-compatible endpoint.",
"api_key": {
"label": "API Key",
"tip": "Multiple keys separated by commas or spaces"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Agents",
"apps": "Apps",
"code": "Code",
"files": "Files",
@@ -4257,6 +4485,7 @@
"notes": "Notes",
"paintings": "Paintings",
"settings": "Settings",
"store": "Assistant Library",
"translate": "Translate"
},
"trace": {
+295 -66
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "添加到助手",
"knowledge_base": {
"label": "知识库",
"placeholder": "选择知识库"
"error": {
"failed": "添加 Agent 失败",
"invalid_agent": "无效的 Agent"
},
"name": {
"label": "名称",
"placeholder": "输入名称"
},
"prompt": {
"label": "提示词",
"placeholder": "输入提示词",
"variables": {
"tip": {
"content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU 架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名",
"title": "可用的变量"
}
}
},
"title": "创建智能体",
"unsaved_changes_warning": "你有未保存的内容,确定要关闭吗?"
},
"delete": {
"popup": {
"content": "确定要删除此智能体吗?"
"title": "添加 Agent",
"type": {
"placeholder": "选择 Agent 类型"
}
},
"delete": {
"content": "删除该 Agent 将强制终止并删除该 Agent 下的所有会话。您确定吗?",
"error": {
"failed": "删除 Agent 失败"
},
"title": "删除 Agent"
},
"edit": {
"model": {
"select": {
"title": "选择模型"
"title": "编辑 Agent"
},
"get": {
"error": {
"failed": "获取智能体失败"
}
},
"list": {
"error": {
"failed": "获取智能体列表失败"
}
},
"session": {
"accessible_paths": {
"add": "添加目录",
"duplicate": "该目录已添加。",
"empty": "请选择至少一个智能体可访问的目录。",
"error": {
"at_least_one": "请至少选择一个可访问的目录"
},
"label": "工作目录",
"select_failed": "选择目录失败"
},
"add": {
"title": "添加会话"
},
"allowed_tools": {
"empty": "当前智能体暂无可用工具。",
"helper": "选择预先授权的工具,未选中的工具在使用时需要手动审批。",
"label": "预先授权工具",
"placeholder": "选择预先授权的工具"
},
"create": {
"error": {
"failed": "添加会话失败"
}
},
"title": "编辑智能体"
},
"export": {
"agent": "导出智能体"
},
"import": {
"button": "导入",
"error": {
"fetch_failed": "从 URL 获取数据失败",
"invalid_format": "无效的代理格式:缺少必填字段",
"url_required": "请输入 URL"
"delete": {
"content": "确定要删除此会话吗?",
"error": {
"failed": "删除会话失败",
"last": "至少需要保留一个会话"
},
"title": "删除会话"
},
"file_filter": "JSON 文件",
"select_file": "选择文件",
"title": "从外部导入",
"type": {
"file": "文件",
"url": "URL"
"edit": {
"title": "编辑会话"
},
"url_placeholder": "输入 JSON URL"
},
"manage": {
"title": "管理智能体"
},
"my_agents": "我的智能体",
"search": {
"no_results": "没有找到相关智能体"
"get": {
"error": {
"failed": "获取会话失败"
}
},
"label_one": "会话",
"label_other": "会话",
"update": {
"error": {
"failed": "更新会话失败"
}
}
},
"settings": {
"title": "智能体配置"
"advance": {
"maxTurns": {
"description": "设定代理自动执行的请求/回复轮次数。",
"helper": "数值越高可自主运行越久;数值越低更易控制。",
"label": "会话轮次数上限"
},
"permissionMode": {
"description": "控制代理在需要授权时的处理方式。",
"label": "权限模式",
"options": {
"acceptEdits": "自动接受编辑",
"bypassPermissions": "跳过权限检查",
"default": "默认(继续前询问)",
"plan": "规划模式(需审批计划)"
},
"placeholder": "选择权限模式"
},
"title": "高级设置"
},
"essential": "基础设置",
"prompt": "提示词设置",
"tooling": {
"mcp": {
"description": "连接 MCP 服务器即可解锁更多可在上方预先授权的工具。",
"empty": "未检测到 MCP 服务器,请前往 MCP 设置页添加。",
"manageHint": "需要更多配置?前往 设置 → MCP 服务器。",
"toggle": "切换 {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "预先授权受信任的文件系统工具,允许即时执行。",
"description": "文件编辑和文件系统操作将自动通过审批。",
"title": "自动接受文件编辑"
},
"bypassPermissions": {
"behavior": "所有工具都会被自动预先授权。",
"description": "所有权限提示都会被跳过,请谨慎使用。",
"title": "跳过所有权限检查",
"warning": "危险:所有工具都会在无审批情况下执行。"
},
"confirmChange": {
"description": "切换模式会更新自动预先授权的工具。",
"title": "确认切换权限模式?"
},
"default": {
"behavior": "只读工具会自动预先授权。",
"description": "只读工具会自动预先授权,其它操作仍需权限。",
"title": "默认(继续前询问)"
},
"helper": "指定智能体如何处理工具使用授权",
"placeholder": "选择权限模式",
"plan": {
"behavior": "默认的只读工具会自动预先授权,但执行仍被禁用。",
"description": "继承默认的只读工具集,并会在执行前先呈现计划。",
"title": "规划模式"
},
"title": "权限模式"
},
"preapproved": {
"autoBadge": "模式自动添加",
"autoDescription": "该工具由当前权限模式自动预先授权。",
"empty": "没有符合筛选条件的工具。",
"mcpBadge": "MCP 工具",
"requiresApproval": "禁用时需要人工审批",
"search": "搜索工具",
"toggle": "切换 {{name}}",
"warning": {
"description": "仅启用你信任的工具。模式默认值会自动标注。",
"title": "预先授权的工具将在无人工审核时运行。"
}
},
"review": {
"autoTools": "自动:{{count}}",
"customTools": "自定义:{{count}}",
"helper": "设置会自动保存,可随时返回上方步骤进行调整。",
"mcp": "MCP{{count}}",
"mode": "模式:{{mode}}"
},
"steps": {
"mcp": {
"title": "MCP 服务器"
},
"permissionMode": {
"title": "步骤 1 · 权限模式"
},
"preapproved": {
"title": "步骤 2 · 预先授权工具"
},
"review": {
"title": "步骤 3 · 总览"
}
},
"tab": "工具与权限"
},
"tools": {
"approved": "已授权",
"caution": "预先授权的工具会跳过人工审核,请仅启用可信的工具。",
"description": "选择哪些工具可以在无需人工审批的情况下执行。",
"requiresPermission": "未预先授权时需要人工审批。",
"tab": "预先授权工具",
"title": "预先授权工具",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "排序"
"type": {
"label": "智能体类型",
"unknown": "未知类型"
},
"tag": {
"agent": "智能体",
"default": "默认",
"new": "新建",
"system": "系统"
"update": {
"error": {
"failed": "更新智能体失败"
}
},
"title": "智能体"
"warning": {
"enable_server": "请启用 API 服务器以使用智能体功能"
}
},
"apiServer": {
"actions": {
@@ -155,9 +276,86 @@
"showByList": "列表展示",
"showByTags": "标签展示"
},
"presets": {
"add": {
"button": "添加到助手",
"knowledge_base": {
"label": "知识库",
"placeholder": "选择知识库"
},
"name": {
"label": "名称",
"placeholder": "输入名称"
},
"prompt": {
"label": "提示词",
"placeholder": "输入提示词",
"variables": {
"tip": {
"content": "{{date}}:\t日期\n{{time}}:\t时间\n{{datetime}}:\t日期和时间\n{{system}}:\t操作系统\n{{arch}}:\tCPU 架构\n{{language}}:\t语言\n{{model_name}}:\t模型名称\n{{username}}:\t用户名",
"title": "可用的变量"
}
}
},
"title": "创建助手",
"unsaved_changes_warning": "你有未保存的内容,确定要关闭吗?"
},
"delete": {
"popup": {
"content": "确定要删除此助手吗?"
}
},
"edit": {
"model": {
"select": {
"title": "选择模型"
}
},
"title": "编辑助手"
},
"export": {
"agent": "导出助手"
},
"import": {
"button": "导入",
"error": {
"fetch_failed": "从 URL 获取数据失败",
"invalid_format": "无效的助手格式:缺少必填字段",
"url_required": "请输入 URL"
},
"file_filter": "JSON 文件",
"select_file": "选择文件",
"title": "从外部导入",
"type": {
"file": "文件",
"url": "URL"
},
"url_placeholder": "输入 JSON URL"
},
"manage": {
"title": "管理助手"
},
"my_agents": "我的助手",
"search": {
"no_results": "没有找到相关助手"
},
"settings": {
"title": "助手配置"
},
"sorting": {
"title": "排序"
},
"tag": {
"agent": "助手",
"default": "默认",
"new": "新建",
"system": "系统"
},
"title": "助手库"
},
"save": {
"success": "保存成功",
"title": "保存到智能体"
"title": "保存到助手库"
},
"search": "搜索助手",
"settings": {
@@ -168,7 +366,7 @@
"label": "调用知识库",
"off": "强制检索",
"on": "意图识别",
"tip": "智能体将调用大模型的意图识别能力,判断是否需要调用知识库进行回答,该功能将依赖模型的能力"
"tip": "助手将调用大模型的意图识别能力,判断是否需要调用知识库进行回答,该功能将依赖模型的能力"
}
},
"mcp": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "添加",
"add_success": "添加成功",
"advanced_settings": "高级设置",
"agent_one": "智能体",
"agent_other": "智能体",
"and": "和",
"assistant": "智能体",
"assistant": "助手",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "头像",
"back": "返回",
"browse": "浏览",
@@ -765,6 +968,8 @@
"default": "默认",
"delete": "删除",
"delete_confirm": "确定要删除吗?",
"delete_failed": "删除失败",
"delete_success": "删除成功",
"description": "描述",
"detail": "详情",
"disabled": "已禁用",
@@ -774,6 +979,10 @@
"edit": "编辑",
"enabled": "已启用",
"error": "错误",
"errors": {
"create_message": "创建消息失败",
"validation": "验证失败"
},
"expand": "展开",
"file": {
"not_supported": "不支持的文件类型 {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "前往设置",
"i_know": "我知道了",
"inspect": "检查",
"invalid_value": "无效值",
"knowledge_base": "知识库",
"language": "语言",
"loading": "加载中...",
@@ -795,6 +1005,11 @@
"none": "无",
"open": "打开",
"paste": "粘贴",
"placeholders": {
"select": {
"model": "选择模型"
}
},
"preview": "预览",
"prompt": "提示词",
"provider": "提供商",
@@ -807,6 +1022,7 @@
"saved": "已保存",
"search": "搜索",
"select": "选择",
"selected": "已选择",
"selectedItems": "已选择 {{count}} 项",
"selectedMessages": "选中 {{count}} 条消息",
"settings": "设置",
@@ -821,6 +1037,9 @@
"success": "成功",
"swap": "交换",
"topics": "话题",
"unknown": "未知",
"unnamed": "未命名",
"update_success": "更新成功",
"upload_files": "上传文件",
"warning": "警告",
"you": "用户"
@@ -893,6 +1112,7 @@
"modelType": "模型类型",
"name": "错误名称",
"no_api_key": "API 密钥未配置",
"no_response": "无响应",
"originalError": "原错误",
"originalMessage": "原消息",
"parameter": "参数",
@@ -958,6 +1178,9 @@
},
"document": "文档",
"edit": "编辑",
"error": {
"open_path": "无法打开路径: {{path}}"
},
"file": "文件",
"image": "图片",
"name": "文件名",
@@ -3795,6 +4018,10 @@
"start_auth": "开始授权",
"submit_code": "完成登录"
},
"anthropic_api_host": "Anthropic API 地址",
"anthropic_api_host_preview": "Anthropic 预览:{{url}}",
"anthropic_api_host_tip": "仅在服务商提供兼容 Anthropic 的地址时填写。以 / 结尾会忽略自动追加的 v1,以 # 结尾则强制使用原始地址。",
"anthropic_api_host_tooltip": "仅当服务商提供 Claude 兼容的基础地址时填写。",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "API 地址",
"api_host_preview": "预览:{{url}}",
"api_host_tooltip": "仅在服务商需要自定义的 OpenAI 兼容地址时覆盖。",
"api_key": {
"label": "API 密钥",
"tip": "多个密钥使用逗号或空格分隔"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "智能体",
"apps": "小程序",
"code": "Code",
"files": "文件",
@@ -4257,6 +4485,7 @@
"notes": "笔记",
"paintings": "绘画",
"settings": "设置",
"store": "助手库",
"translate": "翻译"
},
"trace": {
+294 -65
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "新增到助手",
"knowledge_base": {
"label": "知識庫",
"placeholder": "選擇知識庫"
"error": {
"failed": "無法新增代理人",
"invalid_agent": "無效的 Agent"
},
"name": {
"label": "名稱",
"placeholder": "輸入名稱"
},
"prompt": {
"label": "提示詞",
"placeholder": "輸入提示詞",
"variables": {
"tip": {
"content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU 架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱",
"title": "可用的變數"
}
}
},
"title": "建立智慧代理人",
"unsaved_changes_warning": "有未保存的變更,確定要關閉嗎?"
},
"delete": {
"popup": {
"content": "確定要刪除此智慧代理人嗎?"
"title": "新增代理",
"type": {
"placeholder": "選擇 Agent 類型"
}
},
"delete": {
"content": "刪除該 Agent 將強制終止並刪除該 Agent 下的所有會話。您確定嗎?",
"error": {
"failed": "刪除代理程式失敗"
},
"title": "刪除 Agent"
},
"edit": {
"model": {
"select": {
"title": "選擇模型"
"title": "編輯 Agent"
},
"get": {
"error": {
"failed": "無法取得代理程式。"
}
},
"list": {
"error": {
"failed": "無法列出代理程式。"
}
},
"session": {
"accessible_paths": {
"add": "新增目錄",
"duplicate": "此目錄已包含在內。",
"empty": "選擇至少一個代理可以存取的目錄。",
"error": {
"at_least_one": "請至少選取一個可存取的目錄。"
},
"label": "可存取的目錄",
"select_failed": "無法選擇目錄。"
},
"add": {
"title": "新增會議"
},
"allowed_tools": {
"empty": "目前此代理沒有可用的工具。",
"helper": "選擇預先授權的工具,未選取的工具在使用時需要手動審批。",
"label": "預先授權工具",
"placeholder": "選擇預先授權的工具"
},
"create": {
"error": {
"failed": "無法新增工作階段"
}
},
"title": "編輯智慧代理人"
},
"export": {
"agent": "匯出智慧代理人"
},
"import": {
"button": "導入",
"error": {
"fetch_failed": "從 URL 獲取資料失敗",
"invalid_format": "無效的代理人格式:缺少必填欄位",
"url_required": "請輸入 URL"
"delete": {
"content": "您確定要刪除此工作階段嗎?",
"error": {
"failed": "無法刪除工作階段",
"last": "至少必須保留一個工作階段"
},
"title": "刪除工作階段"
},
"file_filter": "JSON 檔案",
"select_file": "選擇檔案",
"title": "從外部導入",
"type": {
"file": "檔案",
"url": "URL"
"edit": {
"title": "編輯工作階段"
},
"url_placeholder": "輸入 JSON URL"
},
"manage": {
"title": "管理智慧代理人"
},
"my_agents": "我的智慧代理人",
"search": {
"no_results": "沒有找到相關智慧代理人"
"get": {
"error": {
"failed": "無法取得工作階段"
}
},
"label_one": "會議",
"label_other": "Sessions",
"update": {
"error": {
"failed": "無法更新工作階段"
}
}
},
"settings": {
"title": "智慧代理人設定"
"advance": {
"maxTurns": {
"description": "設定代理自動執行的請求/回覆輪次数。",
"helper": "數值越高可自動運行越久;數值越低更容易掌控。",
"label": "會話輪次上限"
},
"permissionMode": {
"description": "控制代理在需要授權時的處理方式。",
"label": "權限模式",
"options": {
"acceptEdits": "自動接受編輯",
"bypassPermissions": "略過權限檢查",
"default": "預設(繼續前先詢問)",
"plan": "規劃模式(需核准計畫)"
},
"placeholder": "選擇權限模式"
},
"title": "進階設定"
},
"essential": "必要設定",
"prompt": "提示設定",
"tooling": {
"mcp": {
"description": "連線 MCP 伺服器即可解鎖更多可在上方預先授權的工具。",
"empty": "尚未偵測到 MCP 伺服器,請前往 MCP 設定頁新增。",
"manageHint": "需要進階設定?前往 設定 → MCP 伺服器。",
"toggle": "切換 {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "預先授權受信任的檔案系統工具,允許即時執行。",
"description": "檔案編輯與檔案系統操作會自動通過核准。",
"title": "自動接受檔案編輯"
},
"bypassPermissions": {
"behavior": "所有工具都會被自動預先授權。",
"description": "所有權限提示都會被略過,請務必謹慎使用。",
"title": "略過所有權限檢查",
"warning": "警告:所有工具都會在無核准情況下執行。"
},
"confirmChange": {
"description": "切換模式會更新自動預先授權的工具。",
"title": "確認切換權限模式?"
},
"default": {
"behavior": "唯讀工具會自動預先授權。",
"description": "唯讀工具會自動預先授權,其它操作仍需核准。",
"title": "預設(繼續前先詢問)"
},
"helper": "指定助手如何處理工具使用授權",
"placeholder": "選擇權限模式",
"plan": {
"behavior": "預設的唯讀工具會自動預先授權,但執行仍被停用。",
"description": "沿用預設的唯讀工具集,並會在執行前先呈現計畫。",
"title": "規劃模式"
},
"title": "權限模式"
},
"preapproved": {
"autoBadge": "模式自動添加",
"autoDescription": "此工具由目前的權限模式自動預先授權。",
"empty": "沒有符合篩選條件的工具。",
"mcpBadge": "MCP 工具",
"requiresApproval": "停用時需要人工核准",
"search": "搜尋工具",
"toggle": "切換 {{name}}",
"warning": {
"description": "僅啟用你信任的工具。模式預設值會自動標示。",
"title": "預先授權的工具將在無人工審查下執行。"
}
},
"review": {
"autoTools": "自動:{{count}}",
"customTools": "自訂:{{count}}",
"helper": "設定會自動儲存,可隨時回到上方步驟調整。",
"mcp": "MCP{{count}}",
"mode": "模式:{{mode}}"
},
"steps": {
"mcp": {
"title": "MCP 伺服器"
},
"permissionMode": {
"title": "步驟 1 · 權限模式"
},
"preapproved": {
"title": "步驟 2 · 預先授權工具"
},
"review": {
"title": "步驟 3 · 檢視"
}
},
"tab": "工具與權限"
},
"tools": {
"approved": "已授權",
"caution": "預先授權的工具會略過人工審查,請僅啟用可信任的工具。",
"description": "選擇哪些工具可在無需人工核准的情況下執行。",
"requiresPermission": "未預先授權時需要人工核准。",
"tab": "預先授權工具",
"title": "預先授權工具",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "排序"
"type": {
"label": "代理類型",
"unknown": "未知類型"
},
"tag": {
"agent": "智慧代理人",
"default": "預設",
"new": "新增",
"system": "系統"
"update": {
"error": {
"failed": "無法更新代理程式"
}
},
"title": "智慧代理人"
"warning": {
"enable_server": "啟用 API 伺服器以使用代理程式。"
}
},
"apiServer": {
"actions": {
@@ -155,9 +276,86 @@
"showByList": "列表展示",
"showByTags": "標籤展示"
},
"presets": {
"add": {
"button": "新增到助手",
"knowledge_base": {
"label": "知識庫",
"placeholder": "選擇知識庫"
},
"name": {
"label": "名稱",
"placeholder": "輸入名稱"
},
"prompt": {
"label": "提示詞",
"placeholder": "輸入提示詞",
"variables": {
"tip": {
"content": "{{date}}:\t日期\n{{time}}:\t時間\n{{datetime}}:\t日期和時間\n{{system}}:\t作業系統\n{{arch}}:\tCPU 架構\n{{language}}:\t語言\n{{model_name}}:\t模型名稱\n{{username}}:\t使用者名稱",
"title": "可用的變數"
}
}
},
"title": "建立助手",
"unsaved_changes_warning": "有未保存的變更,確定要關閉嗎?"
},
"delete": {
"popup": {
"content": "確定要刪除此助手嗎?"
}
},
"edit": {
"model": {
"select": {
"title": "選擇模型"
}
},
"title": "編輯助手"
},
"export": {
"agent": "匯出助手"
},
"import": {
"button": "導入",
"error": {
"fetch_failed": "從 URL 獲取資料失敗",
"invalid_format": "無效的助手格式:缺少必填欄位",
"url_required": "請輸入 URL"
},
"file_filter": "JSON 檔案",
"select_file": "選擇檔案",
"title": "從外部導入",
"type": {
"file": "檔案",
"url": "URL"
},
"url_placeholder": "輸入 JSON URL"
},
"manage": {
"title": "管理助手"
},
"my_agents": "我的助手",
"search": {
"no_results": "沒有找到相關助手"
},
"settings": {
"title": "助手配置"
},
"sorting": {
"title": "排序"
},
"tag": {
"agent": "助手",
"default": "預設",
"new": "新增",
"system": "系統"
},
"title": "助手庫"
},
"save": {
"success": "儲存成功",
"title": "儲存到智慧代理人"
"title": "儲存到助手庫"
},
"search": "搜尋助手...",
"settings": {
@@ -168,7 +366,7 @@
"label": "調用知識庫",
"off": "強制檢索",
"on": "意圖識別",
"tip": "智慧代理人將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力"
"tip": "助手將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力"
}
},
"mcp": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "新增",
"add_success": "新增成功",
"advanced_settings": "進階設定",
"agent_one": "代理人",
"agent_other": "代理人",
"and": "與",
"assistant": "智慧代理人",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "頭像",
"back": "返回",
"browse": "瀏覽",
@@ -765,6 +968,8 @@
"default": "預設",
"delete": "刪除",
"delete_confirm": "確定要刪除嗎?",
"delete_failed": "刪除失敗",
"delete_success": "刪除成功",
"description": "描述",
"detail": "詳情",
"disabled": "已停用",
@@ -774,6 +979,10 @@
"edit": "編輯",
"enabled": "已啟用",
"error": "錯誤",
"errors": {
"create_message": "無法建立訊息",
"validation": "驗證失敗"
},
"expand": "展開",
"file": {
"not_supported": "不支持的文件類型 {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "前往設定",
"i_know": "我知道了",
"inspect": "檢查",
"invalid_value": "無效值",
"knowledge_base": "知識庫",
"language": "語言",
"loading": "加載中...",
@@ -795,6 +1005,11 @@
"none": "無",
"open": "開啟",
"paste": "貼上",
"placeholders": {
"select": {
"model": "選擇模型"
}
},
"preview": "預覽",
"prompt": "提示詞",
"provider": "供應商",
@@ -807,6 +1022,7 @@
"saved": "已儲存",
"search": "搜尋",
"select": "選擇",
"selected": "已選擇",
"selectedItems": "已選擇 {{count}} 項",
"selectedMessages": "選中 {{count}} 條訊息",
"settings": "設定",
@@ -821,6 +1037,9 @@
"success": "成功",
"swap": "交換",
"topics": "話題",
"unknown": "Unknown",
"unnamed": "未命名",
"update_success": "更新成功",
"upload_files": "上傳檔案",
"warning": "警告",
"you": "您"
@@ -893,6 +1112,7 @@
"modelType": "模型類型",
"name": "錯誤名稱",
"no_api_key": "API 金鑰未設定",
"no_response": "無回應",
"originalError": "原錯誤",
"originalMessage": "原消息",
"parameter": "參數",
@@ -958,6 +1178,9 @@
},
"document": "文件",
"edit": "編輯",
"error": {
"open_path": "無法開啟路徑: {{path}}"
},
"file": "檔案",
"image": "圖片",
"name": "名稱",
@@ -3795,6 +4018,10 @@
"start_auth": "開始授權",
"submit_code": "完成登錄"
},
"anthropic_api_host": "Anthropic API 主機地址",
"anthropic_api_host_preview": "Anthropic 預覽:{{url}}",
"anthropic_api_host_tip": "僅在服務商提供與 Anthropic 相容的網址時設定。以 / 結尾會忽略自動附加的 v1,以 # 結尾則強制使用原始地址。",
"anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "API 主機地址",
"api_host_preview": "預覽:{{url}}",
"api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。",
"api_key": {
"label": "API 金鑰",
"tip": "多個金鑰使用逗號或空格分隔"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "智能體",
"apps": "小程序",
"code": "Code",
"files": "文件",
@@ -4257,6 +4485,7 @@
"notes": "筆記",
"paintings": "繪畫",
"settings": "設定",
"store": "助手庫",
"translate": "翻譯"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Προσθήκη στο Βοηθό",
"knowledge_base": {
"label": "Βάση γνώσεων",
"placeholder": "Επιλέξτε βάση γνώσεων"
"error": {
"failed": "Αποτυχία προσθήκης πράκτορα",
"invalid_agent": "Μη έγκυρος Agent"
},
"name": {
"label": "Όνομα",
"placeholder": "Εισαγάγετε όνομα"
},
"prompt": {
"label": "Φράση προκαλέσεως",
"placeholder": "Εισαγάγετε φράση προκαλέσεως",
"variables": {
"tip": {
"content": "{{date}}:\tΗμερομηνία\n{{time}}:\tΏρα\n{{datetime}}:\tΗμερομηνία και ώρα\n{{system}}:\tΛειτουργικό σύστημα\n{{arch}}:\tΑρχιτεκτονική CPU\n{{language}}:\tΓλώσσα\n{{model_name}}:\tΌνομα μοντέλου\n{{username}}:\tΌνομα χρήστη",
"title": "Διαθέσιμες μεταβλητές"
}
}
},
"title": "Δημιουργία νέου ειδικού",
"unsaved_changes_warning": "Έχετε μη αποθηκευμένες αλλαγές, είστε βέβαιοι ότι θέλετε να κλείσετε;"
},
"delete": {
"popup": {
"content": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον ειδικό;"
"title": "Προσθήκη Agent",
"type": {
"placeholder": "Επιλέξτε τύπο Agent"
}
},
"delete": {
"content": "Η διαγραφή αυτού του Agent θα τερματίσει βίαια και θα διαγράψει όλες τις συνεδρίες υπό αυτόν τον Agent. Είστε σίγουροι;",
"error": {
"failed": "Αποτυχία διαγραφής του πράκτορα"
},
"title": "Διαγραφή Agent"
},
"edit": {
"model": {
"select": {
"title": "Επιλογή μοντέλου"
"title": "Επεξεργαστής Agent"
},
"get": {
"error": {
"failed": "Αποτυχία λήψης του πράκτορα."
}
},
"list": {
"error": {
"failed": "Αποτυχία καταχώρησης πρακτόρων."
}
},
"session": {
"accessible_paths": {
"add": "Προσθήκη καταλόγου",
"duplicate": "Αυτός ο κατάλογος έχει ήδη συμπεριληφθεί.",
"empty": "Επιλέξτε τουλάχιστον έναν κατάλογο στον οποίο ο πράκτορας μπορεί να έχει πρόσβαση.",
"error": {
"at_least_one": "Παρακαλώ επιλέξτε τουλάχιστον έναν προσβάσιμο κατάλογο."
},
"label": "Προσβάσιμοι κατάλογοι",
"select_failed": "Αποτυχία επιλογής καταλόγου."
},
"add": {
"title": "Προσθήκη συνεδρίας"
},
"allowed_tools": {
"empty": "Δεν υπάρχουν διαθέσιμα εργαλεία για αυτόν τον agent.",
"helper": "Επιλέξτε ποια εργαλεία είναι προεγκεκριμένα. Τα μη επιλεγμένα θα χρειαστούν έγκριση πριν από τη χρήση.",
"label": "Προεγκεκριμένα εργαλεία",
"placeholder": "Επιλέξτε προεγκεκριμένα εργαλεία"
},
"create": {
"error": {
"failed": "Αποτυχία προσθήκης συνεδρίας"
}
},
"title": "Επεξεργασία ειδικού"
},
"export": {
"agent": "Εξαγωγή υποκειμένου"
},
"import": {
"button": "Εισαγωγή",
"error": {
"fetch_failed": "Αποτυχία λήψης δεδομένων από το URL",
"invalid_format": "Μη έγκυρη μορφή πράκτορα: λείπουν υποχρεωτικά πεδία",
"url_required": "Παρακαλώ εισάγετε τη διεύθυνση URL"
"delete": {
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;",
"error": {
"failed": "Αποτυχία διαγραφής της συνεδρίας",
"last": "Τουλάχιστον μία συνεδρία πρέπει να διατηρηθεί"
},
"title": "Διαγραφή συνεδρίας"
},
"file_filter": "Αρχεία JSON",
"select_file": "Επιλέξτε αρχείο",
"title": "Εισαγωγή από το εξωτερικό",
"type": {
"file": "Αρχείο",
"url": "URL"
"edit": {
"title": "Συνεδρία επεξεργασίας"
},
"url_placeholder": "Εισάγετε τη διεύθυνση URL JSON"
},
"manage": {
"title": "Διαχείριση ειδικών"
},
"my_agents": "Οι ειδικοί μου",
"search": {
"no_results": "Δεν βρέθηκαν σχετικοί ειδικοί"
"get": {
"error": {
"failed": "Αποτυχία λήψης της συνεδρίας"
}
},
"label_one": "Συνεδρία",
"label_other": "Συνεδρίες",
"update": {
"error": {
"failed": "Αποτυχία ενημέρωσης της συνεδρίας"
}
}
},
"settings": {
"title": "Διαμόρφωση Πράκτορα"
"advance": {
"maxTurns": {
"description": "Ορίστε τον αριθμό γύρων αιτήματος/απάντησης που θα εκτελούνται αυτόματα από τον διαμεσολαβητή.",
"helper": "Όσο υψηλότερη είναι η τιμή, τόσο περισσότερο μπορεί να λειτουργεί αυτόνομα· όσο χαμηλότερη είναι, τόσο πιο εύκολα ελέγχεται.",
"label": "Όριο αριθμού γύρων συνεδρίας"
},
"permissionMode": {
"description": "Ο τρόπος με τον οποίο ο πληρεξούσιος ελεγκτής χειρίζεται την κατάσταση όταν απαιτείται εξουσιοδότηση.",
"label": "Λειτουργία δικαιωμάτων",
"options": {
"acceptEdits": "Αυτόματη αποδοχή επεξεργασίας",
"bypassPermissions": "παράλειψη ελέγχου δικαιωμάτων",
"default": "Προεπιλογή (να ερωτηθεί πριν από τη συνέχεια)",
"plan": "Λειτουργία σχεδιασμού (απαιτείται έγκριση σχεδίου)"
},
"placeholder": "Επιλέξτε λειτουργία δικαιωμάτων"
},
"title": "Ρυθμίσεις για προχωρημένους"
},
"essential": "Βασικές Ρυθμίσεις",
"prompt": "Ρυθμίσεις Προτροπής",
"tooling": {
"mcp": {
"description": "Συνδέστε διακομιστές MCP για να ξεκλειδώσετε πρόσθετα εργαλεία που μπορείτε να εγκρίνετε παραπάνω.",
"empty": "Δεν εντοπίστηκαν διακομιστές MCP. Προσθέστε έναν από τη σελίδα ρυθμίσεων MCP.",
"manageHint": "Χρειάζεστε προηγμένη διαμόρφωση; Επισκεφθείτε Ρυθμίσεις → Διακομιστές MCP.",
"toggle": "Εναλλαγή {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Προεγκρίνει αξιόπιστα εργαλεία συστήματος αρχείων ώστε οι επεξεργασίες να εκτελούνται αμέσως.",
"description": "Οι επεξεργασίες αρχείων και οι λειτουργίες του συστήματος αρχείων εγκρίνονται αυτόματα.",
"title": "Αυτόματη αποδοχή επεξεργασιών αρχείων"
},
"bypassPermissions": {
"behavior": "Κάθε εργαλείο εγκρίνεται αυτόματα εκ των προτέρων.",
"description": "Όλα τα αιτήματα άδειας παραλείπονται — χρησιμοποιήστε με προσοχή.",
"title": "Παράκαμψη ελέγχων αδειών",
"warning": "Χρησιμοποιήστε με προσοχή — όλα τα εργαλεία θα εκτελεστούν χωρίς να ζητηθεί έγκριση."
},
"confirmChange": {
"description": "Η αλλαγή λειτουργιών ενημερώνει τα εργαλεία που εγκρίνονται αυτόματα.",
"title": "Αλλαγή λειτουργίας δικαιωμάτων;"
},
"default": {
"behavior": "Κανένα εργαλείο δεν εγκρίνεται αυτόματα εκ των προτέρων.",
"description": "Ισχύουν οι κανονικοί έλεγχοι δικαιωμάτων.",
"title": "Προεπιλογή (να ερωτηθώ πριν συνεχίσω)"
},
"helper": "Καθορίζει πώς ο πράκτορας διαχειρίζεται την εξουσιοδότηση χρήσης εργαλείων",
"placeholder": "Επιλέξτε λειτουργία αδειών",
"plan": {
"behavior": "Μόνο εργαλεία μόνο για ανάγνωση. Η εκτέλεση είναι απενεργοποιημένη.",
"description": "Ο Claude μπορεί να χρησιμοποιεί μόνο εργαλεία μόνο για ανάγνωση και παρουσιάζει ένα σχέδιο πριν από την εκτέλεση.",
"title": "Λειτουργία σχεδιασμού (έρχεται σύντομα)"
},
"title": "Λειτουργία αδειών"
},
"preapproved": {
"autoBadge": "Προστέθηκε από τη λειτουργία",
"autoDescription": "Αυτό το εργαλείο έχει εγκριθεί αυτόματα από την τρέχουσα λειτουργία δικαιωμάτων.",
"empty": "Δεν υπάρχουν εργαλεία που να ταιριάζουν με τα φίλτρα σας.",
"mcpBadge": "εργαλείο MCP",
"requiresApproval": "Απαιτείται έγκριση όταν είναι απενεργοποιημένο",
"search": "Εργαλεία αναζήτησης",
"toggle": "Εναλλαγή {{name}}",
"warning": {
"description": "Ενεργοποιήστε μόνο εργαλεία που εμπιστεύεστε. Οι προεπιλογές λειτουργίας τονίζονται αυτόματα.",
"title": "Τα προεγκεκριμένα εργαλεία εκτελούνται χωρίς χειροκίνητη αναθεώρηση."
}
},
"review": {
"autoTools": "Αυτόματο: {{count}}",
"customTools": "Προσαρμοσμένο: {{count}}",
"helper": "Οι αλλαγές αποθηκεύονται αυτόματα. Προσαρμόστε τα παραπάνω βήματα ανά πάσα στιγμή για να εξειδικεύσετε τα δικαιώματα.",
"mcp": "MCP: {{count}}",
"mode": "Λειτουργία: {{mode}}"
},
"steps": {
"mcp": {
"title": "Διακομιστές MCP"
},
"permissionMode": {
"title": "Βήμα 1 · Λειτουργία αδειών"
},
"preapproved": {
"title": "Βήμα 2 · Προεγκεκριμένα εργαλεία"
},
"review": {
"title": "Βήμα 3 · Ανασκόπηση"
}
},
"tab": "Εργαλεία & άδειες"
},
"tools": {
"approved": "εγκεκριμένο",
"caution": "Εργαλεία προεγκεκριμένα παρακάμπτουν την ανθρώπινη αξιολόγηση. Ενεργοποιήστε μόνο έμπιστα εργαλεία.",
"description": "Επιλέξτε ποια εργαλεία μπορούν να εκτελούνται χωρίς χειροκίνητη έγκριση.",
"requiresPermission": "Απαιτείται άδεια όταν δεν έχει προεγκριθεί.",
"tab": "Προεγκεκριμένα εργαλεία",
"title": "Προεγκεκριμένα εργαλεία",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Ταξινόμηση"
"type": {
"label": "Τύπος Πράκτορα",
"unknown": "Άγνωστος Τύπος"
},
"tag": {
"agent": "Ειδικός",
"default": "Προεπιλογή",
"new": "Νέος",
"system": "Σύστημα"
"update": {
"error": {
"failed": "Αποτυχία ενημέρωσης του πράκτορα"
}
},
"title": "Ειδικοί"
"warning": {
"enable_server": "Ενεργοποίηση του διακομιστή API για χρήση πρακτόρων."
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "Εμφάνιση με λίστα",
"showByTags": "Εμφάνιση με ετικέτες"
},
"presets": {
"add": {
"button": "Προσθήκη στον βοηθό",
"knowledge_base": {
"label": "Βάση γνώσης",
"placeholder": "Επιλέξτε βάση γνώσης"
},
"name": {
"label": "Όνομα",
"placeholder": "Εισάγετε όνομα"
},
"prompt": {
"label": "Προτροπή",
"placeholder": "Εισάγετε προτροπή",
"variables": {
"tip": {
"content": "{{date}}:\tΗμερομηνία\n{{time}}:\tΏρα\n{{datetime}}:\tΗμερομηνία και ώρα\n{{system}}:\tΛειτουργικό σύστημα\n{{arch}}:\tΑρχιτεκτονική CPU\n{{language}}:\tΓλώσσα\n{{model_name}}:\tΌνομα μοντέλου\n{{username}}:\tΌνομα χρήστη",
"title": "Διαθέσιμες μεταβλητές"
}
}
},
"title": "Δημιουργία βοηθού",
"unsaved_changes_warning": "Έχετε μη αποθηκευμένες αλλαγές. Είστε σίγουροι ότι θέλετε να κλείσετε;"
},
"delete": {
"popup": {
"content": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτόν τον βοηθό;"
}
},
"edit": {
"model": {
"select": {
"title": "Επιλέξτε μοντέλο"
}
},
"title": "Επεξεργασία βοηθού"
},
"export": {
"agent": "Εξαγωγή βοηθού"
},
"import": {
"button": "Εισαγωγή",
"error": {
"fetch_failed": "Αποτυχία λήψης δεδομένων από το URL",
"invalid_format": "Μη έγκυρη μορφή βοηθού: λείπουν υποχρεωτικά πεδία",
"url_required": "Παρακαλώ εισάγετε ένα URL"
},
"file_filter": "Αρχεία JSON",
"select_file": "Επιλέξτε αρχείο",
"title": "Εισαγωγή από εξωτερική πηγή",
"type": {
"file": "Αρχείο",
"url": "URL"
},
"url_placeholder": "Εισάγετε JSON URL"
},
"manage": {
"title": "Διαχείριση βοηθών"
},
"my_agents": "Οι βοηθοί μου",
"search": {
"no_results": "Δεν βρέθηκαν σχετικοί βοηθοί"
},
"settings": {
"title": "Διαμόρφωση βοηθών"
},
"sorting": {
"title": "Ταξινόμηση"
},
"tag": {
"agent": "Βοηθός",
"default": "Προεπιλογή",
"new": "Νέος",
"system": "Σύστημα"
},
"title": "Βιβλιοθήκη βοηθών"
},
"save": {
"success": "Η αποθήκευση ολοκληρώθηκε επιτυχώς",
"title": "Αποθήκευση στον νοητή"
@@ -334,7 +532,7 @@
"new_topic": "Νέο θέμα {{Command}}",
"pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Εδώ εισαγάγετε το μήνυμα, πατήστε {{key}} για αποστολή",
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",
"send": "Αποστολή",
"settings": "Ρυθμίσεις",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Προσθέστε",
"add_success": "Η προσθήκη ήταν επιτυχής",
"advanced_settings": "Προχωρημένες ρυθμίσεις",
"agent_one": "Πράκτορας",
"agent_other": "Πράκτορες",
"and": "και",
"assistant": "Εξυπνιασμένη Ενότητα",
"assistant_one": "βοηθός",
"assistant_other": "βοηθός",
"avatar": "Εικονίδιο",
"back": "Πίσω",
"browse": "Περιήγηση",
@@ -765,6 +968,8 @@
"default": "Προεπιλογή",
"delete": "Διαγραφή",
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
"delete_failed": "Αποτυχία διαγραφής",
"delete_success": "Η διαγραφή ήταν επιτυχής",
"description": "Περιγραφή",
"detail": "Λεπτομέρειες",
"disabled": "Απενεργοποιημένο",
@@ -774,6 +979,10 @@
"edit": "Επεξεργασία",
"enabled": "Ενεργοποιημένο",
"error": "σφάλμα",
"errors": {
"create_message": "Αποτυχία δημιουργίας μηνύματος",
"validation": "Η επαλήθευση απέτυχε"
},
"expand": "Επεκτάση",
"file": {
"not_supported": "Μη υποστηριζόμενος τύπος αρχείου {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Πηγαίνετε στις ρυθμίσεις",
"i_know": "Το έχω καταλάβει",
"inspect": "Επιθεώρηση",
"invalid_value": "Μη έγκυρη τιμή",
"knowledge_base": "Βάση Γνώσεων",
"language": "Γλώσσα",
"loading": "Φόρτωση...",
@@ -795,6 +1005,11 @@
"none": "Χωρίς",
"open": "Άνοιγμα",
"paste": "Επικόλληση",
"placeholders": {
"select": {
"model": "Επιλέξτε μοντέλο"
}
},
"preview": "Προεπισκόπηση",
"prompt": "Ενδεικτικός ρήματος",
"provider": "Παρέχων",
@@ -807,6 +1022,7 @@
"saved": "Αποθηκεύτηκε",
"search": "Αναζήτηση",
"select": "Επιλογή",
"selected": "Επιλεγμένο",
"selectedItems": "Επιλέχθηκαν {{count}} αντικείμενα",
"selectedMessages": "Επιλέχθηκαν {{count}} μηνύματα",
"settings": "Ρυθμίσεις",
@@ -821,6 +1037,9 @@
"success": "Επιτυχία",
"swap": "Εναλλαγή",
"topics": "Θέματα",
"unknown": "Άγνωστο",
"unnamed": "Χωρίς όνομα",
"update_success": "Επιτυχής ενημέρωση",
"upload_files": "Ανέβασμα αρχείου",
"warning": "Προσοχή",
"you": "Εσείς"
@@ -893,6 +1112,7 @@
"modelType": "Τύπος μοντέλου",
"name": "Λάθος όνομα",
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
"no_response": "Καμία απάντηση",
"originalError": "Αρχικό σφάλμα",
"originalMessage": "Αρχικό μήνυμα",
"parameter": "παράμετροι",
@@ -958,6 +1178,9 @@
},
"document": "Έγγραφο",
"edit": "Επεξεργασία",
"error": {
"open_path": "Δεν είναι δυνατό το άνοιγμα της διαδρομής: {{path}}"
},
"file": "Αρχείο",
"image": "Εικόνα",
"name": "Όνομα αρχείου",
@@ -3795,6 +4018,10 @@
"start_auth": "Έναρξη εξουσιοδότησης",
"submit_code": "Ολοκληρώστε την σύνδεση"
},
"anthropic_api_host": "Διεύθυνση API Anthropic",
"anthropic_api_host_preview": "Προεπισκόπηση Anthropic: {{url}}",
"anthropic_api_host_tip": "Συμπληρώστε μόνο εάν ο πάροχος προσφέρει συμβατή με Anthropic διεύθυνση. Η λήξη με / αγνοεί το v1 που προστίθεται αυτόματα, η λήξη με # επιβάλλει τη χρήση της αρχικής διεύθυνσης.",
"anthropic_api_host_tooltip": "Συμπληρώστε μόνο όταν ο πάροχος παρέχει βασική διεύθυνση συμβατή με Claude.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "Διεύθυνση API",
"api_host_preview": "Προεπισκόπηση: {{url}}",
"api_host_tooltip": "Αντικατάσταση μόνο όταν ο πάροχος απαιτεί προσαρμοσμένη διεύθυνση συμβατή με OpenAI.",
"api_key": {
"label": "Κλειδί API",
"tip": "Χωριστά με κόμμα περισσότερα κλειδιά API"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Πράκτορες",
"apps": "Εφαρμογές",
"code": "Κώδικας",
"files": "Αρχεία",
@@ -4257,6 +4485,7 @@
"notes": "σημειώσεις",
"paintings": "Ζωγραφική",
"settings": "Ρυθμίσεις",
"store": "Βιβλιοθήκη βοηθών",
"translate": "Μετάφραση"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Agregar al asistente",
"knowledge_base": {
"label": "Base de conocimiento",
"placeholder": "Seleccionar base de conocimiento"
"error": {
"failed": "Error al añadir agente",
"invalid_agent": "Agent inválido"
},
"name": {
"label": "Nombre",
"placeholder": "Ingrese el nombre"
},
"prompt": {
"label": "Palabra clave",
"placeholder": "Ingrese la palabra clave",
"variables": {
"tip": {
"content": "{{date}}:\tFecha\n{{time}}:\tHora\n{{datetime}}:\tFecha y hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitectura de CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNombre del modelo\n{{username}}:\tNombre de usuario",
"title": "Variables disponibles"
}
}
},
"title": "Crear agente inteligente",
"unsaved_changes_warning": "Tiene contenido no guardado, ¿está seguro de que desea cerrar?"
},
"delete": {
"popup": {
"content": "¿Está seguro de que desea eliminar este agente inteligente?"
"title": "Agregar Agente",
"type": {
"placeholder": "Seleccionar tipo de Agente"
}
},
"delete": {
"content": "Eliminar este Agente forzará la terminación y eliminación de todas las sesiones bajo este Agente. ¿Está seguro?",
"error": {
"failed": "Error al eliminar el agente"
},
"title": "Eliminar Agent"
},
"edit": {
"model": {
"select": {
"title": "Seleccionar modelo"
"title": "Agent de edición"
},
"get": {
"error": {
"failed": "No se pudo obtener el agente."
}
},
"list": {
"error": {
"failed": "Error al listar agentes."
}
},
"session": {
"accessible_paths": {
"add": "Agregar directorio",
"duplicate": "Este directorio ya está incluido.",
"empty": "Selecciona al menos un directorio al que el agente pueda acceder.",
"error": {
"at_least_one": "Por favor, seleccione al menos un directorio accesible."
},
"label": "Directorios accesibles",
"select_failed": "Error al seleccionar el directorio."
},
"add": {
"title": "Agregar una sesión"
},
"allowed_tools": {
"empty": "No hay herramientas disponibles para este agente.",
"helper": "Elige qué herramientas quedan preaprobadas. Las no seleccionadas requerirán aprobación manual antes de usarse.",
"label": "Herramientas preaprobadas",
"placeholder": "Seleccionar herramientas preaprobadas"
},
"create": {
"error": {
"failed": "Error al añadir una sesión"
}
},
"title": "Editar agente inteligente"
},
"export": {
"agent": "Exportar Agente"
},
"import": {
"button": "Importar",
"error": {
"fetch_failed": "Error al obtener los datos de la URL",
"invalid_format": "Formato de proxy no válido: faltan campos obligatorios",
"url_required": "Por favor, introduzca la URL"
"delete": {
"content": "¿Estás seguro de eliminar esta sesión?",
"error": {
"failed": "Error al eliminar la sesión",
"last": "Debe mantenerse al menos una sesión"
},
"title": "Eliminar sesión"
},
"file_filter": "Archivos JSON",
"select_file": "Seleccionar archivo",
"title": "Importar desde el exterior",
"type": {
"file": "Archivo",
"url": "URL"
"edit": {
"title": "Sesión de edición"
},
"url_placeholder": "Ingrese la URL JSON"
},
"manage": {
"title": "Administrar agentes inteligentes"
},
"my_agents": "Mis agentes inteligentes",
"search": {
"no_results": "No se encontraron agentes relacionados"
"get": {
"error": {
"failed": "Error al obtener la sesión"
}
},
"label_one": "Sesión",
"label_other": "Sesiones",
"update": {
"error": {
"failed": "Error al actualizar la sesión"
}
}
},
"settings": {
"title": "Configuración del Agente"
"advance": {
"maxTurns": {
"description": "Establece el número de rondas de solicitud/respuesta que el agente ejecutará automáticamente.",
"helper": "Cuanto mayor es el valor, más tiempo puede funcionar de forma autónoma; cuanto menor es el valor, más fácil es de controlar.",
"label": "Límite máximo de turnos de conversación"
},
"permissionMode": {
"description": "Cómo el agente de control maneja las situaciones que requieren autorización.",
"label": "modo de permisos",
"options": {
"acceptEdits": "Aceptar ediciones automáticamente",
"bypassPermissions": "Omitir verificación de permisos",
"default": "Predeterminado (preguntar antes de continuar)",
"plan": "Modo de planificación (requiere aprobación del plan)"
},
"placeholder": "Seleccionar modo de permisos"
},
"title": "Configuración avanzada"
},
"essential": "Configuraciones esenciales",
"prompt": "Configuración de indicaciones",
"tooling": {
"mcp": {
"description": "Conecta servidores MCP para desbloquear herramientas adicionales que puedes aprobar arriba.",
"empty": "No se detectaron servidores MCP. Añade uno desde la página de configuración de MCP.",
"manageHint": "¿Necesitas configuración avanzada? Visita Configuración → Servidores MCP.",
"toggle": "Alternar {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Preaprueba herramientas de sistema de archivos de confianza para que las ediciones se ejecuten inmediatamente.",
"description": "Las ediciones de archivos y las operaciones del sistema de archivos se aprueban automáticamente.",
"title": "Aceptar automáticamente las ediciones de archivos"
},
"bypassPermissions": {
"behavior": "Cada herramienta está pre-aprobada automáticamente.",
"description": "Todos los mensajes de permiso se omiten — úsalo con precaución.",
"title": "Omitir verificaciones de permisos",
"warning": "Usar con precaución: todas las herramientas se ejecutarán sin pedir aprobación."
},
"confirmChange": {
"description": "Cambiar de modo actualiza las herramientas aprobadas automáticamente.",
"title": "¿Cambiar modo de permisos?"
},
"default": {
"behavior": "No se aprueban herramientas automáticamente de antemano.",
"description": "Se aplican los controles de permisos normales.",
"title": "Predeterminado (preguntar antes de continuar)"
},
"helper": "Especifica cómo el agente maneja la autorización de uso de herramientas",
"placeholder": "Seleccionar modo de permisos",
"plan": {
"behavior": "Solo herramientas de solo lectura. La ejecución está deshabilitada.",
"description": "Claude solo puede usar herramientas de solo lectura y presenta un plan antes de ejecutarlo.",
"title": "Modo de planificación (próximamente)"
},
"title": "Modo de permisos"
},
"preapproved": {
"autoBadge": "Añadido por modo",
"autoDescription": "Esta herramienta está aprobada automáticamente por el modo de permisos actual.",
"empty": "Ninguna herramienta coincide con tus filtros.",
"mcpBadge": "herramienta MCP",
"requiresApproval": "Requiere aprobación cuando está deshabilitado",
"search": "Herramientas de búsqueda",
"toggle": "Alternar {{name}}",
"warning": {
"description": "Habilita solo las herramientas en las que confíes. Los valores predeterminados del modo se resaltan automáticamente.",
"title": "Herramientas preaprobadas se ejecutan sin revisión manual."
}
},
"review": {
"autoTools": "Auto: {{count}}",
"customTools": "Personalizado: {{count}}",
"helper": "Los cambios se guardan automáticamente. Ajusta los pasos anteriores en cualquier momento para afinar los permisos.",
"mcp": "MCP: {{count}}",
"mode": "Modo: {{mode}}"
},
"steps": {
"mcp": {
"title": "servidores MCP"
},
"permissionMode": {
"title": "Paso 1 · Modo de permiso"
},
"preapproved": {
"title": "Paso 2 · Herramientas preaprobadas"
},
"review": {
"title": "Paso 3 · Revisar"
}
},
"tab": "Herramientas y permisos"
},
"tools": {
"approved": "aprobado",
"caution": "Herramientas preaprobadas omiten la revisión humana. Habilita solo herramientas de confianza.",
"description": "Elige qué herramientas pueden ejecutarse sin aprobación manual.",
"requiresPermission": "Requiere permiso cuando no está preaprobado.",
"tab": "Herramientas preaprobadas",
"title": "Herramientas preaprobadas",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Ordenar"
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo desconocido"
},
"tag": {
"agent": "Agente",
"default": "Predeterminado",
"new": "Nuevo",
"system": "Sistema"
"update": {
"error": {
"failed": "Error al actualizar el agente"
}
},
"title": "Agente"
"warning": {
"enable_server": "Habilitar el servidor API para usar agentes."
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "Mostrar en lista",
"showByTags": "Mostrar por etiquetas"
},
"presets": {
"add": {
"button": "Añadir al asistente",
"knowledge_base": {
"label": "Base de conocimientos",
"placeholder": "Seleccionar base de conocimientos"
},
"name": {
"label": "Nombre",
"placeholder": "Introducir nombre"
},
"prompt": {
"label": "Prompt",
"placeholder": "Introducir prompt",
"variables": {
"tip": {
"content": "{{date}}:\tFecha\n{{time}}:\tHora\n{{datetime}}:\tFecha y hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitectura CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNombre del modelo\n{{username}}:\tNombre de usuario",
"title": "Variables disponibles"
}
}
},
"title": "Crear asistente",
"unsaved_changes_warning": "Tienes cambios sin guardar. ¿Estás seguro de que quieres cerrar?"
},
"delete": {
"popup": {
"content": "¿Estás seguro de que quieres eliminar este asistente?"
}
},
"edit": {
"model": {
"select": {
"title": "Seleccionar modelo"
}
},
"title": "Editar asistente"
},
"export": {
"agent": "Exportar asistente"
},
"import": {
"button": "Importar",
"error": {
"fetch_failed": "Error al obtener datos desde la URL",
"invalid_format": "Formato de asistente inválido: faltan campos obligatorios",
"url_required": "Por favor introduce una URL"
},
"file_filter": "Archivos JSON",
"select_file": "Seleccionar archivo",
"title": "Importar desde externo",
"type": {
"file": "Archivo",
"url": "URL"
},
"url_placeholder": "Introducir URL JSON"
},
"manage": {
"title": "Gestionar asistentes"
},
"my_agents": "Mis asistentes",
"search": {
"no_results": "No se encontraron asistentes relacionados"
},
"settings": {
"title": "Configuración de asistentes"
},
"sorting": {
"title": "Ordenar"
},
"tag": {
"agent": "Asistente",
"default": "Por defecto",
"new": "Nuevo",
"system": "Sistema"
},
"title": "Biblioteca de asistentes"
},
"save": {
"success": "Guardado exitosamente",
"title": "Guardar en Agente Inteligente"
@@ -334,7 +532,7 @@
"new_topic": "Nuevo tema {{Command}}",
"pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escriba un mensaje aquí y presione {{key}} para enviar",
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",
"send": "Enviar",
"settings": "Configuración",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Agregar",
"add_success": "Añadido con éxito",
"advanced_settings": "Configuración avanzada",
"agent_one": "Agente",
"agent_other": "Agentes",
"and": "y",
"assistant": "Agente inteligente",
"assistant_one": "Asistente",
"assistant_other": "Asistente",
"avatar": "Avatar",
"back": "Atrás",
"browse": "Examinar",
@@ -765,6 +968,8 @@
"default": "Predeterminado",
"delete": "Eliminar",
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
"delete_failed": "Error al eliminar",
"delete_success": "Eliminación exitosa",
"description": "Descripción",
"detail": "Detalles",
"disabled": "Desactivado",
@@ -774,6 +979,10 @@
"edit": "Editar",
"enabled": "Activado",
"error": "error",
"errors": {
"create_message": "Error al crear el mensaje",
"validation": "Fallo en la verificación"
},
"expand": "Expandir",
"file": {
"not_supported": "Tipo de archivo no compatible {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Ir a la configuración",
"i_know": "Entendido",
"inspect": "Inspeccionar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de conocimiento",
"language": "Idioma",
"loading": "Cargando...",
@@ -795,6 +1005,11 @@
"none": "无",
"open": "Abrir",
"paste": "Pegar",
"placeholders": {
"select": {
"model": "Seleccionar modelo"
}
},
"preview": "Vista previa",
"prompt": "Prompt",
"provider": "Proveedor",
@@ -807,6 +1022,7 @@
"saved": "Guardado",
"search": "Buscar",
"select": "Seleccionar",
"selected": "Seleccionado",
"selectedItems": "{{count}} elementos seleccionados",
"selectedMessages": "{{count}} mensajes seleccionados",
"settings": "Configuración",
@@ -821,6 +1037,9 @@
"success": "Éxito",
"swap": "Intercambiar",
"topics": "Temas",
"unknown": "Desconocido",
"unnamed": "Sin nombre",
"update_success": "Actualización exitosa",
"upload_files": "Subir archivo",
"warning": "Advertencia",
"you": "Usuario"
@@ -893,6 +1112,7 @@
"modelType": "Tipo de modelo",
"name": "Nombre de error",
"no_api_key": "La clave API no está configurada",
"no_response": "Sin respuesta",
"originalError": "Error original",
"originalMessage": "mensaje original",
"parameter": "parámetro",
@@ -958,6 +1178,9 @@
},
"document": "Documento",
"edit": "Editar",
"error": {
"open_path": "No se puede abrir la ruta: {{path}}"
},
"file": "Archivo",
"image": "Imagen",
"name": "Nombre del archivo",
@@ -3795,6 +4018,10 @@
"start_auth": "Comenzar autorización",
"submit_code": "Iniciar sesión completado"
},
"anthropic_api_host": "Dirección API de Anthropic",
"anthropic_api_host_preview": "Vista previa de Anthropic: {{url}}",
"anthropic_api_host_tip": "Rellenar solo si el proveedor ofrece una dirección compatible con Anthropic. Terminar con / ignora el v1 añadido automáticamente, terminar con # fuerza el uso de la dirección original.",
"anthropic_api_host_tooltip": "Rellenar solo cuando el proveedor proporcione una dirección base compatible con Claude.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "Dirección API",
"api_host_preview": "Vista previa: {{url}}",
"api_host_tooltip": "Sobrescribir solo cuando el proveedor necesite una dirección compatible con OpenAI personalizada.",
"api_key": {
"label": "Clave API",
"tip": "Separar múltiples claves con comas"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Agentes",
"apps": "Aplicaciones",
"code": "Código",
"files": "Archivos",
@@ -4257,6 +4485,7 @@
"notes": "notas",
"paintings": "Pinturas",
"settings": "Configuración",
"store": "Biblioteca de asistentes",
"translate": "Traducir"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Ajouter à l'assistant",
"knowledge_base": {
"label": "Base de connaissances",
"placeholder": "Sélectionner une base de connaissances"
"error": {
"failed": "Échec de l'ajout de l'agent",
"invalid_agent": "Agent invalide"
},
"name": {
"label": "Nom",
"placeholder": "Entrer le nom"
},
"prompt": {
"label": "Mot-clé",
"placeholder": "Entrer le mot-clé",
"variables": {
"tip": {
"content": "{{date}}:\tDate\n{{time}}:\tHeure\n{{datetime}}:\tDate et heure\n{{system}}:\tSystème d'exploitation\n{{arch}}:\tArchitecture du processeur\n{{language}}:\tLangue\n{{model_name}}:\tNom du modèle\n{{username}}:\tNom d'utilisateur",
"title": "Variables disponibles"
}
}
},
"title": "Créer un agent intelligent",
"unsaved_changes_warning": "Vous avez des modifications non enregistrées, êtes-vous sûr de vouloir fermer ?"
},
"delete": {
"popup": {
"content": "Êtes-vous sûr de vouloir supprimer cet agent intelligent ?"
"title": "Ajouter un agent",
"type": {
"placeholder": "Sélectionner le type d'Agent"
}
},
"delete": {
"content": "La suppression de cet Agent entraînera la terminaison forcée et la suppression de toutes les sessions associées. Êtes-vous certain ?",
"error": {
"failed": "Échec de la suppression de l'agent"
},
"title": "Supprimer l'Agent"
},
"edit": {
"model": {
"select": {
"title": "Sélectionner un modèle"
"title": "Éditer Agent"
},
"get": {
"error": {
"failed": "Échec de l'obtention de l'agent."
}
},
"list": {
"error": {
"failed": "Échec de la liste des agents."
}
},
"session": {
"accessible_paths": {
"add": "Ajouter un répertoire",
"duplicate": "Ce répertoire est déjà inclus.",
"empty": "Sélectionnez au moins un répertoire auquel l'agent peut accéder.",
"error": {
"at_least_one": "Veuillez sélectionner au moins un répertoire accessible."
},
"label": "Répertoires accessibles",
"select_failed": "Échec de la sélection du répertoire."
},
"add": {
"title": "Ajouter une session"
},
"allowed_tools": {
"empty": "Aucun outil disponible pour cet agent.",
"helper": "Choisissez les outils préapprouvés. Les outils non sélectionnés nécessiteront une approbation avant utilisation.",
"label": "Outils pré-approuvés",
"placeholder": "Sélectionner des outils pré-approuvés"
},
"create": {
"error": {
"failed": "Échec de l'ajout d'une session"
}
},
"title": "Modifier l'agent intelligent"
},
"export": {
"agent": "Экспортировать агента"
},
"import": {
"button": "Импортировать",
"error": {
"fetch_failed": "Échec de la récupération des données depuis l'URL",
"invalid_format": "Format de proxy invalide : champs obligatoires manquants",
"url_required": "Veuillez entrer l'URL"
"delete": {
"content": "Êtes-vous sûr de vouloir supprimer cette session ?",
"error": {
"failed": "Échec de la suppression de la session",
"last": "Au moins une session doit être conservée"
},
"title": "Supprimer la session"
},
"file_filter": "Файлы JSON",
"select_file": "Выбрать файл",
"title": "Импорт из внешнего источника",
"type": {
"file": "Fichier",
"url": "URL"
"edit": {
"title": "Session d'édition"
},
"url_placeholder": "Введите URL JSON"
},
"manage": {
"title": "Gérer les agents intelligents"
},
"my_agents": "Mes agents intelligents",
"search": {
"no_results": "Aucun agent intelligent correspondant trouvé"
"get": {
"error": {
"failed": "Échec de l'obtention de la session"
}
},
"label_one": "Session",
"label_other": "Séances",
"update": {
"error": {
"failed": "Échec de la mise à jour de la session"
}
}
},
"settings": {
"title": "Configuration de l'agent intelligent"
"advance": {
"maxTurns": {
"description": "Définir le nombre de cycles de requête/réponse exécutés automatiquement par l'agent.",
"helper": "Une valeur plus élevée permet une autonomie prolongée ; une valeur plus faible facilite le contrôle.",
"label": "Limite maximale de tours de conversation"
},
"permissionMode": {
"description": "Contrôle la manière dont l'agent gère les demandes d'autorisation.",
"label": "mode d'autorisation",
"options": {
"acceptEdits": "Accepter automatiquement les modifications",
"bypassPermissions": "Passer la vérification des autorisations",
"default": "Par défaut (demander avant de continuer)",
"plan": "Mode de planification (plan soumis à approbation)"
},
"placeholder": "Choisir le mode d'autorisation"
},
"title": "Paramètres avancés"
},
"essential": "Paramètres essentiels",
"prompt": "Paramètres de l'invite",
"tooling": {
"mcp": {
"description": "Connectez des serveurs MCP pour débloquer des outils supplémentaires que vous pouvez approuver ci-dessus.",
"empty": "Aucun serveur MCP détecté. Ajoutez-en un depuis la page des paramètres MCP.",
"manageHint": "Besoin d'une configuration avancée ? Visitez Paramètres → Serveurs MCP.",
"toggle": "Basculer {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Pré-approuve les outils de système de fichiers de confiance afin que les modifications s'exécutent immédiatement.",
"description": "Les modifications de fichiers et les opérations sur le système de fichiers sont automatiquement approuvées.",
"title": "Accepter automatiquement les modifications de fichiers"
},
"bypassPermissions": {
"behavior": "Chaque outil est pré-approuvé automatiquement.",
"description": "Toutes les demandes de permission sont ignorées — à utiliser avec prudence.",
"title": "Contourner les vérifications de permission",
"warning": "Utiliser avec prudence — tous les outils s'exécuteront sans demander d'approbation."
},
"confirmChange": {
"description": "Le changement de mode met à jour les outils approuvés automatiquement.",
"title": "Changer le mode d'autorisation ?"
},
"default": {
"behavior": "Aucun outil nest pré-approuvé automatiquement.",
"description": "Les vérifications d'autorisation normales s'appliquent.",
"title": "Par défaut (demander avant de continuer)"
},
"helper": "Spécifie comment l'agent gère l'autorisation d'utilisation des outils",
"placeholder": "Sélectionner le mode de permission",
"plan": {
"behavior": "Outils en lecture seule uniquement. L'exécution est désactivée.",
"description": "Claude ne peut utiliser que des outils en lecture seule et présente un plan avant l'exécution.",
"title": "Mode de planification (à venir)"
},
"title": "Mode de permission"
},
"preapproved": {
"autoBadge": "Ajouté par mode",
"autoDescription": "Cet outil est automatiquement approuvé par le mode d'autorisation actuel.",
"empty": "Aucun outil ne correspond à vos filtres.",
"mcpBadge": "outil MCP",
"requiresApproval": "Nécessite une approbation lorsquil est désactivé",
"search": "Outils de recherche",
"toggle": "Basculer {{name}}",
"warning": {
"description": "Activez uniquement les outils en lesquels vous avez confiance. Les paramètres par défaut du mode sont mis en surbrillance automatiquement.",
"title": "Les outils pré-approuvés s'exécutent sans révision manuelle."
}
},
"review": {
"autoTools": "Auto : {{count}}",
"customTools": "Personnalisé : {{count}}",
"helper": "Les modifications sont enregistrées automatiquement. Ajustez les étapes ci-dessus à tout moment pour affiner les autorisations.",
"mcp": "MCP : {{count}}",
"mode": "Mode : {{mode}}"
},
"steps": {
"mcp": {
"title": "Serveurs MCP"
},
"permissionMode": {
"title": "Étape 1 · Mode dautorisation"
},
"preapproved": {
"title": "Étape 2 · Outils pré-approuvés"
},
"review": {
"title": "Étape 3 · Révision"
}
},
"tab": "Outils et autorisations"
},
"tools": {
"approved": "approuvé",
"caution": "Outils pré-approuvés contournent la révision humaine. Activez uniquement les outils de confiance.",
"description": "Choisissez quels outils peuvent s'exécuter sans approbation manuelle.",
"requiresPermission": "Nécessite une autorisation lorsqu'elle n'est pas préapprouvée.",
"tab": "Outils pré-approuvés",
"title": "Outils pré-approuvés",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Trier"
"type": {
"label": "Type d'agent",
"unknown": "Type inconnu"
},
"tag": {
"agent": "Agent intelligent",
"default": "Par défaut",
"new": "Nouveau",
"system": "Système"
"update": {
"error": {
"failed": "Échec de la mise à jour de l'agent"
}
},
"title": "Agent intelligent"
"warning": {
"enable_server": "Permettre au serveur API d'utiliser des agents."
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "Affichage sous forme de liste",
"showByTags": "Affichage par balises"
},
"presets": {
"add": {
"button": "Ajouter à l'assistant",
"knowledge_base": {
"label": "Base de connaissances",
"placeholder": "Sélectionner une base de connaissances"
},
"name": {
"label": "Nom",
"placeholder": "Saisir le nom"
},
"prompt": {
"label": "Prompt",
"placeholder": "Saisir le prompt",
"variables": {
"tip": {
"content": "{{date}}:\tDate\n{{time}}:\tHeure\n{{datetime}}:\tDate et heure\n{{system}}:\tSystème d'exploitation\n{{arch}}:\tArchitecture CPU\n{{language}}:\tLangue\n{{model_name}}:\tNom du modèle\n{{username}}:\tNom d'utilisateur",
"title": "Variables disponibles"
}
}
},
"title": "Créer un assistant",
"unsaved_changes_warning": "Vous avez des modifications non sauvegardées. Êtes-vous sûr de vouloir fermer ?"
},
"delete": {
"popup": {
"content": "Êtes-vous sûr de vouloir supprimer cet assistant ?"
}
},
"edit": {
"model": {
"select": {
"title": "Sélectionner un modèle"
}
},
"title": "Modifier l'assistant"
},
"export": {
"agent": "Exporter l'assistant"
},
"import": {
"button": "Importer",
"error": {
"fetch_failed": "Échec de la récupération des données depuis l'URL",
"invalid_format": "Format d'assistant invalide : champs obligatoires manquants",
"url_required": "Veuillez saisir une URL"
},
"file_filter": "Fichiers JSON",
"select_file": "Sélectionner un fichier",
"title": "Importer depuis l'extérieur",
"type": {
"file": "Fichier",
"url": "URL"
},
"url_placeholder": "Saisir l'URL JSON"
},
"manage": {
"title": "Gérer les assistants"
},
"my_agents": "Mes assistants",
"search": {
"no_results": "Aucun assistant correspondant trouvé"
},
"settings": {
"title": "Configuration des assistants"
},
"sorting": {
"title": "Tri"
},
"tag": {
"agent": "Assistant",
"default": "Par défaut",
"new": "Nouveau",
"system": "Système"
},
"title": "Bibliothèque d'assistants"
},
"save": {
"success": "Sauvegarde réussie",
"title": "Enregistrer dans l'agent"
@@ -334,7 +532,7 @@
"new_topic": "Nouveau sujet {{Command}}",
"pause": "Pause",
"placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Entrez votre message ici, appuyez sur {{key}} pour envoyer",
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",
"send": "Envoyer",
"settings": "Paramètres",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Ajouter",
"add_success": "Ajout réussi",
"advanced_settings": "Paramètres avancés",
"agent_one": "Agent",
"agent_other": "Agents",
"and": "et",
"assistant": "Intelligence artificielle",
"assistant_one": "assistant",
"assistant_other": "assistant",
"avatar": "Avatar",
"back": "Retour",
"browse": "Parcourir",
@@ -765,6 +968,8 @@
"default": "Défaut",
"delete": "Supprimer",
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
"delete_failed": "Échec de la suppression",
"delete_success": "Suppression réussie",
"description": "Description",
"detail": "détails",
"disabled": "Désactivé",
@@ -774,6 +979,10 @@
"edit": "Éditer",
"enabled": "Activé",
"error": "erreur",
"errors": {
"create_message": "Échec de la création du message",
"validation": "Échec de la vérification"
},
"expand": "Développer",
"file": {
"not_supported": "Type de fichier non pris en charge {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Aller aux paramètres",
"i_know": "J'ai compris",
"inspect": "Vérifier",
"invalid_value": "valeur invalide",
"knowledge_base": "Base de connaissances",
"language": "Langue",
"loading": "Chargement...",
@@ -795,6 +1005,11 @@
"none": "Aucun",
"open": "Ouvrir",
"paste": "Coller",
"placeholders": {
"select": {
"model": "Choisir le modèle"
}
},
"preview": "Aperçu",
"prompt": "Prompt",
"provider": "Fournisseur",
@@ -807,6 +1022,7 @@
"saved": "enregistré",
"search": "Rechercher",
"select": "Sélectionner",
"selected": "Sélectionné",
"selectedItems": "{{count}} éléments sélectionnés",
"selectedMessages": "{{count}} messages sélectionnés",
"settings": "Paramètres",
@@ -821,6 +1037,9 @@
"success": "Succès",
"swap": "Échanger",
"topics": "Sujets",
"unknown": "Inconnu",
"unnamed": "Sans nom",
"update_success": "Mise à jour réussie",
"upload_files": "Uploader des fichiers",
"warning": "Avertissement",
"you": "Vous"
@@ -893,6 +1112,7 @@
"modelType": "Type de modèle",
"name": "Nom d'erreur",
"no_api_key": "La clé API n'est pas configurée",
"no_response": "Pas de réponse",
"originalError": "Erreur d'origine",
"originalMessage": "message original",
"parameter": "paramètre",
@@ -958,6 +1178,9 @@
},
"document": "Document",
"edit": "Éditer",
"error": {
"open_path": "Impossible d'ouvrir le chemin : {{path}}"
},
"file": "Fichier",
"image": "Image",
"name": "Nom du fichier",
@@ -3795,6 +4018,10 @@
"start_auth": "Commencer l'autorisation",
"submit_code": "Terminer la connexion"
},
"anthropic_api_host": "Adresse API Anthropic",
"anthropic_api_host_preview": "Aperçu Anthropic : {{url}}",
"anthropic_api_host_tip": "Remplir seulement si le fournisseur propose une adresse compatible Anthropic. Se terminant par / ignore le v1 ajouté automatiquement, se terminant par # force l'utilisation de l'adresse originale.",
"anthropic_api_host_tooltip": "Remplir seulement lorsque le fournisseur propose une adresse de base compatible Claude.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "Adresse API",
"api_host_preview": "Aperçu : {{url}}",
"api_host_tooltip": "Remplacer seulement lorsque le fournisseur nécessite une adresse compatible OpenAI personnalisée.",
"api_key": {
"label": "Clé API",
"tip": "Séparer les clés multiples par des virgules"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Agent intelligent",
"apps": "Mini-programmes",
"code": "Code",
"files": "Fichiers",
@@ -4257,6 +4485,7 @@
"notes": "notes",
"paintings": "Peintures",
"settings": "Paramètres",
"store": "Bibliothèque d'assistants",
"translate": "Traduire"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "アシスタントに追加",
"knowledge_base": {
"label": "ナレッジベース",
"placeholder": "ナレッジベースを選択"
"error": {
"failed": "エージェントの追加に失敗しました",
"invalid_agent": "無効なエージェント"
},
"name": {
"label": "名前",
"placeholder": "名前を入力"
},
"prompt": {
"label": "プロンプト",
"placeholder": "プロンプトを入力",
"variables": {
"tip": {
"content": "{{date}}:\t日付\n{{time}}:\t時間\n{{datetime}}:\t日付と時間\n{{system}}:\tオペレーティングシステム\n{{arch}}:\tCPUアーキテクチャ\n{{language}}:\t言語\n{{model_name}}:\tモデル名\n{{username}}:\tユーザー名",
"title": "利用可能な変数"
}
}
},
"title": "エージェントを作成",
"unsaved_changes_warning": "未保存の変更があります。続行しますか?"
},
"delete": {
"popup": {
"content": "このエージェントを削除してもよろしいですか?"
"title": "エージェントを追加",
"type": {
"placeholder": "エージェントタイプを選択"
}
},
"delete": {
"content": "このエージェントを削除すると、このエージェントのすべてのセッションが強制的に終了し、削除されます。本当によろしいですか?",
"error": {
"failed": "エージェントの削除に失敗しました"
},
"title": "エージェントを削除"
},
"edit": {
"model": {
"select": {
"title": "モデルを選択"
"title": "編集エージェント"
},
"get": {
"error": {
"failed": "エージェントの取得に失敗しました。"
}
},
"list": {
"error": {
"failed": "エージェントの一覧取得に失敗しました。"
}
},
"session": {
"accessible_paths": {
"add": "ディレクトリを追加",
"duplicate": "このディレクトリは既に含まれています。",
"empty": "エージェントがアクセスできるディレクトリを少なくとも1つ選択してください。",
"error": {
"at_least_one": "アクセス可能なディレクトリを少なくとも1つ選択してください。"
},
"label": "アクセス可能なディレクトリ",
"select_failed": "ディレクトリの選択に失敗しました。"
},
"add": {
"title": "セッションを追加"
},
"allowed_tools": {
"empty": "このエージェントが利用できるツールはありません。",
"helper": "事前承認済みのツールを選択します。未選択のツールは使用時に承認が必要になります。",
"label": "事前承認済みツール",
"placeholder": "事前承認するツールを選択"
},
"create": {
"error": {
"failed": "セッションの追加に失敗しました"
}
},
"title": "エージェントを編集"
},
"export": {
"agent": "エージェントをエクスポート"
},
"import": {
"button": "インポート",
"error": {
"fetch_failed": "URLからのデータ取得に失敗しました",
"invalid_format": "無効なエージェント形式:必須フィールドが不足しています",
"url_required": "URLを入力してください"
"delete": {
"content": "このセッションを削除してもよろしいですか?",
"error": {
"failed": "セッションの削除に失敗しました",
"last": "少なくとも1つのセッションを維持する必要があります"
},
"title": "セッションを削除"
},
"file_filter": "JSONファイル",
"select_file": "ファイルを選択",
"title": "外部からインポート",
"type": {
"file": "ファイル",
"url": "URL"
"edit": {
"title": "編集セッション"
},
"url_placeholder": "JSON URLを入力"
},
"manage": {
"title": "エージェントを管理"
},
"my_agents": "マイエージェント",
"search": {
"no_results": "結果が見つかりません"
"get": {
"error": {
"failed": "セッションの取得に失敗しました"
}
},
"label_one": "セッション",
"label_other": "セッション",
"update": {
"error": {
"failed": "セッションの更新に失敗しました"
}
}
},
"settings": {
"title": "エージェント設定"
"advance": {
"maxTurns": {
"description": "プロキシが自動的に実行するリクエスト/レスポンスのラウンド数を設定します。",
"helper": "数値が高いほど自律動作の時間が長くなり、数値が低いほど制御しやすくなります。",
"label": "会話ラウンド数の上限"
},
"permissionMode": {
"description": "制御エージェントが認可を必要とする場合の処理方法。",
"label": "権限モード",
"options": {
"acceptEdits": "自動的に編集を受け入れる",
"bypassPermissions": "権限チェックをスキップ",
"default": "デフォルト(続行する前に確認)",
"plan": "計画モード(承認が必要な計画)"
},
"placeholder": "権限モードを選択"
},
"title": "高級設定"
},
"essential": "必須設定",
"prompt": "プロンプト設定",
"tooling": {
"mcp": {
"description": "MCPサーバーを接続して、上で承認できる追加ツールを解放します。",
"empty": "MCPサーバーが検出されませんでした。MCP設定ページから追加してください。",
"manageHint": "高度な設定が必要ですか?設定 → MCPサーバーにアクセスしてください。",
"toggle": "{{name}}を切り替え"
},
"permissionMode": {
"acceptEdits": {
"behavior": "信頼できるファイルシステムツールを事前に承認し、編集が即座に実行されるようにします。",
"description": "ファイルの編集とファイルシステムの操作は自動的に承認されます。",
"title": "ファイル編集を自動承認"
},
"bypassPermissions": {
"behavior": "すべてのツールは自動的に事前承認されます。",
"description": "すべての権限プロンプトはスキップされます — 注意して使用してください。",
"title": "権限チェックをバイパス",
"warning": "注意して使用してください — すべてのツールは承認なしで実行されます。"
},
"confirmChange": {
"description": "モードを切り替えると、自動承認されたツールが更新されます。",
"title": "パーミッションモードを変更しますか?"
},
"default": {
"behavior": "ツールは自動的に事前承認されません。",
"description": "通常の権限チェックが適用されます。",
"title": "デフォルト(続行する前に確認)"
},
"helper": "エージェントがツール使用の承認を処理する方法を指定",
"placeholder": "権限モードを選択",
"plan": {
"behavior": "読み取り専用ツールのみ。実行は無効です。",
"description": "Claudeは読み取り専用ツールのみを使用し、実行前に計画を提示します。",
"title": "計画モード(近日公開)"
},
"title": "権限モード"
},
"preapproved": {
"autoBadge": "モードによって追加されました",
"autoDescription": "このツールは現在の権限モードによって自動承認されています。",
"empty": "フィルターに一致するツールはありません。",
"mcpBadge": "MCPツール",
"requiresApproval": "無効にするには承認が必要",
"search": "検索ツール",
"toggle": "{{name}}を切り替える",
"warning": {
"description": "信頼できるツールのみを有効にしてください。モードのデフォルトは自動的に強調表示されます。",
"title": "事前承認されたツールは手動でのレビューなしに実行されます。"
}
},
"review": {
"autoTools": "自動: {{count}}",
"customTools": "カスタム: {{count}}",
"helper": "変更は自動的に保存されます。権限を微調整するには、上記の手順をいつでも調整してください。",
"mcp": "MCP: {{count}}",
"mode": "モード: {{mode}}"
},
"steps": {
"mcp": {
"title": "MCPサーバー"
},
"permissionMode": {
"title": "ステップ1・権限モード"
},
"preapproved": {
"title": "ステップ2・事前承認済みツール"
},
"review": {
"title": "ステップ3・レビュー"
}
},
"tab": "ツールと権限"
},
"tools": {
"approved": "承認済み",
"caution": "事前承認したツールは人によるレビューをスキップします。信頼できるツールのみ有効にしてください。",
"description": "人による承認なしで実行できるツールを選択します。",
"requiresPermission": "事前承認されていない場合は承認が必要です。",
"tab": "事前承認済みツール",
"title": "事前承認済みツール",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "並び替え"
"type": {
"label": "エージェントタイプ",
"unknown": "不明なタイプ"
},
"tag": {
"agent": "エージェント",
"default": "デフォルト",
"new": "新規",
"system": "システム"
"update": {
"error": {
"failed": "エージェントの更新に失敗しました"
}
},
"title": "エージェント"
"warning": {
"enable_server": "APIサーバーがエージェントを使用できるようにする。"
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "リスト表示",
"showByTags": "タグ表示"
},
"presets": {
"add": {
"button": "アシスタントに追加",
"knowledge_base": {
"label": "ナレッジベース",
"placeholder": "ナレッジベースを選択"
},
"name": {
"label": "名前",
"placeholder": "名前を入力"
},
"prompt": {
"label": "プロンプト",
"placeholder": "プロンプトを入力",
"variables": {
"tip": {
"content": "{{date}}:\t日付\n{{time}}:\t時間\n{{datetime}}:\t日付と時間\n{{system}}:\tオペレーティングシステム\n{{arch}}:\tCPUアーキテクチャ\n{{language}}:\t言語\n{{model_name}}:\tモデル名\n{{username}}:\tユーザー名",
"title": "利用可能な変数"
}
}
},
"title": "アシスタントを作成",
"unsaved_changes_warning": "未保存の変更があります。閉じてもよろしいですか?"
},
"delete": {
"popup": {
"content": "このアシスタントを削除してもよろしいですか?"
}
},
"edit": {
"model": {
"select": {
"title": "モデルを選択"
}
},
"title": "アシスタントを編集"
},
"export": {
"agent": "アシスタントをエクスポート"
},
"import": {
"button": "インポート",
"error": {
"fetch_failed": "URLからのデータ取得に失敗しました",
"invalid_format": "無効なアシスタント形式:必須フィールドが不足しています",
"url_required": "URLを入力してください"
},
"file_filter": "JSONファイル",
"select_file": "ファイルを選択",
"title": "外部からインポート",
"type": {
"file": "ファイル",
"url": "URL"
},
"url_placeholder": "JSON URLを入力"
},
"manage": {
"title": "アシスタントを管理"
},
"my_agents": "マイアシスタント",
"search": {
"no_results": "関連するアシスタントが見つかりません"
},
"settings": {
"title": "アシスタント設定"
},
"sorting": {
"title": "並び替え"
},
"tag": {
"agent": "アシスタント",
"default": "デフォルト",
"new": "新規",
"system": "システム"
},
"title": "アシスタントライブラリ"
},
"save": {
"success": "保存に成功しました",
"title": "エージェントに保存"
@@ -334,7 +532,7 @@
"new_topic": "新しいトピック {{Command}}",
"pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信してください",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",
"send": "送信",
"settings": "設定",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "追加",
"add_success": "追加成功",
"advanced_settings": "詳細設定",
"agent_one": "エージェント",
"agent_other": "エージェント",
"and": "と",
"assistant": "アシスタント",
"assistant_one": "助手",
"assistant_other": "助手",
"avatar": "アバター",
"back": "戻る",
"browse": "参照",
@@ -765,6 +968,8 @@
"default": "デフォルト",
"delete": "削除",
"delete_confirm": "削除してもよろしいですか?",
"delete_failed": "削除に失敗しました",
"delete_success": "削除に成功しました",
"description": "説明",
"detail": "詳細",
"disabled": "無効",
@@ -774,6 +979,10 @@
"edit": "編集",
"enabled": "有効",
"error": "エラー",
"errors": {
"create_message": "メッセージの作成に失敗しました",
"validation": "検証に失敗しました"
},
"expand": "展開",
"file": {
"not_supported": "サポートされていないファイルタイプ {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "設定に移動",
"i_know": "わかりました",
"inspect": "検査",
"invalid_value": "無効な値",
"knowledge_base": "ナレッジベース",
"language": "言語",
"loading": "読み込み中...",
@@ -795,6 +1005,11 @@
"none": "無",
"open": "開く",
"paste": "貼り付け",
"placeholders": {
"select": {
"model": "モデルを選択"
}
},
"preview": "プレビュー",
"prompt": "プロンプト",
"provider": "プロバイダー",
@@ -807,6 +1022,7 @@
"saved": "保存されました",
"search": "検索",
"select": "選択",
"selected": "選択済み",
"selectedItems": "{{count}}件の項目を選択しました",
"selectedMessages": "{{count}}件のメッセージを選択しました",
"settings": "設定",
@@ -821,6 +1037,9 @@
"success": "成功",
"swap": "交換",
"topics": "トピック",
"unknown": "Unknown",
"unnamed": "無題",
"update_success": "更新成功",
"upload_files": "ファイルをアップロードする",
"warning": "警告",
"you": "あなた"
@@ -893,6 +1112,7 @@
"modelType": "モデルの種類",
"name": "エラー名",
"no_api_key": "APIキーが設定されていません",
"no_response": "応答なし",
"originalError": "元のエラー",
"originalMessage": "元のメッセージ",
"parameter": "パラメータ",
@@ -958,6 +1178,9 @@
},
"document": "ドキュメント",
"edit": "編集",
"error": {
"open_path": "パスを開けません: {{path}}"
},
"file": "ファイル",
"image": "画像",
"name": "名前",
@@ -3795,6 +4018,10 @@
"start_auth": "開始承認",
"submit_code": "ログインを完了する"
},
"anthropic_api_host": "Anthropic APIアドレス",
"anthropic_api_host_preview": "Anthropic プレビュー:{{url}}",
"anthropic_api_host_tip": "サービスプロバイダーがAnthropic互換のアドレスを提供する場合のみ入力してください。/で終わる場合は自動追加されるv1を無視し、#で終わる場合は元のアドレスを強制的に使用します。",
"anthropic_api_host_tooltip": "サービスプロバイダーがClaude互換のベースアドレスを提供する場合のみ入力してください。",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "APIホスト",
"api_host_preview": "プレビュー:{{url}}",
"api_host_tooltip": "サービスプロバイダーがカスタムOpenAI互換アドレスを必要とする場合のみ上書きしてください。",
"api_key": {
"label": "APIキー",
"tip": "複数のキーはカンマまたはスペースで区切ります"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "エージェント",
"apps": "アプリ",
"code": "Code",
"files": "ファイル",
@@ -4257,6 +4485,7 @@
"notes": "ノート",
"paintings": "ペインティング",
"settings": "設定",
"store": "アシスタントライブラリ",
"translate": "翻訳"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Adicionar ao Assistente",
"knowledge_base": {
"label": "Base de Conhecimento",
"placeholder": "Selecione a Base de Conhecimento"
"error": {
"failed": "Falha ao adicionar agente",
"invalid_agent": "Agent inválido"
},
"name": {
"label": "Nome",
"placeholder": "Digite o Nome"
},
"prompt": {
"label": "Prompt",
"placeholder": "Digite o Prompt",
"variables": {
"tip": {
"content": "{{date}}:\tData\n{{time}}:\tHora\n{{datetime}}:\tData e hora\n{{system}}:\tSistema operativo\n{{arch}}:\tArquitetura da CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNome do modelo\n{{username}}:\tNome de utilizador",
"title": "Variáveis disponíveis"
}
}
},
"title": "Criar Agente Inteligente",
"unsaved_changes_warning": "Você tem alterações não salvas, tem certeza de que deseja fechar?"
},
"delete": {
"popup": {
"content": "Tem certeza de que deseja excluir este agente inteligente?"
"title": "Adicionar Agente",
"type": {
"placeholder": "Selecionar tipo de Agente"
}
},
"delete": {
"content": "Excluir este Agente forçará a terminação e exclusão de todas as sessões sob ele. Tem certeza?",
"error": {
"failed": "Falha ao excluir o agente"
},
"title": "删除代理"
},
"edit": {
"model": {
"select": {
"title": "Selecionar Modelo"
"title": "Agent Editor"
},
"get": {
"error": {
"failed": "Falha ao obter o agente."
}
},
"list": {
"error": {
"failed": "Falha ao listar agentes."
}
},
"session": {
"accessible_paths": {
"add": "Adicionar diretório",
"duplicate": "Este diretório já está incluído.",
"empty": "Selecione pelo menos um diretório ao qual o agente possa acessar.",
"error": {
"at_least_one": "Por favor, selecione pelo menos um diretório acessível."
},
"label": "Diretórios acessíveis",
"select_failed": "Falha ao selecionar o diretório."
},
"add": {
"title": "Adicionar uma sessão"
},
"allowed_tools": {
"empty": "Não há ferramentas disponíveis para este agente.",
"helper": "Escolha quais ferramentas ficam pré-autorizadas. As não selecionadas exigirão aprovação antes do uso.",
"label": "Ferramentas pré-aprovadas",
"placeholder": "Selecionar ferramentas pré-aprovadas"
},
"create": {
"error": {
"failed": "Falha ao adicionar uma sessão"
}
},
"title": "Editar Agente Inteligente"
},
"export": {
"agent": "Exportar Agente"
},
"import": {
"button": "Importar",
"error": {
"fetch_failed": "Falha ao buscar dados da URL",
"invalid_format": "Formato de proxy inválido: campos obrigatórios ausentes",
"url_required": "Por favor, insira a URL"
"delete": {
"content": "Tem certeza de que deseja excluir esta sessão?",
"error": {
"failed": "Falha ao excluir a sessão",
"last": "Pelo menos uma sessão deve ser mantida"
},
"title": "Excluir sessão"
},
"file_filter": "Arquivo JSON",
"select_file": "Selecionar arquivo",
"title": "Importar do exterior",
"type": {
"file": "Arquivo",
"url": "URL"
"edit": {
"title": "Sessão de edição"
},
"url_placeholder": "Insira o URL JSON"
},
"manage": {
"title": "Gerenciar Agentes Inteligentes"
},
"my_agents": "Meus Agentes Inteligentes",
"search": {
"no_results": "Nenhum agente inteligente encontrado"
"get": {
"error": {
"failed": "Falha ao obter a sessão"
}
},
"label_one": "Sessão",
"label_other": "Sessões",
"update": {
"error": {
"failed": "Falha ao atualizar a sessão"
}
}
},
"settings": {
"title": "Configuração do Agente"
"advance": {
"maxTurns": {
"description": "Defina o número de ciclos de solicitação/resposta executados automaticamente pelo agente.",
"helper": "Quanto maior o valor, mais tempo pode funcionar de forma autônoma; quanto menor o valor, mais fácil de controlar.",
"label": "Limite máximo de turnos de conversação"
},
"permissionMode": {
"description": "Como o agente de controle lida com situações que exigem autorização.",
"label": "Modo de permissão",
"options": {
"acceptEdits": "Aceitar edições automaticamente",
"bypassPermissions": "忽略检查 de permissão",
"default": "Padrão (perguntar antes de continuar)",
"plan": "Modo de planejamento (plano sujeito a aprovação)"
},
"placeholder": "Selecionar modo de permissão"
},
"title": "Configurações avançadas"
},
"essential": "Configurações Essenciais",
"prompt": "Configurações de Prompt",
"tooling": {
"mcp": {
"description": "Conecte servidores MCP para desbloquear ferramentas adicionais que você pode aprovar acima.",
"empty": "Nenhum servidor MCP detectado. Adicione um na página de configurações do MCP.",
"manageHint": "Precisa de configuração avançada? Visite Configurações → Servidores MCP.",
"toggle": "Alternar {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Pré-aplica ferramentas confiáveis do sistema de arquivos para que as edições sejam executadas imediatamente.",
"description": "As edições de arquivos e operações do sistema de arquivos são aprovadas automaticamente.",
"title": "Aceitar automaticamente edições de arquivos"
},
"bypassPermissions": {
"behavior": "Cada ferramenta é pré-aprovada automaticamente.",
"description": "Todos os pedidos de permissão são ignorados — use com cautela.",
"title": "Ignorar verificações de permissão",
"warning": "Use com cautela — todas as ferramentas serão executadas sem solicitar aprovação."
},
"confirmChange": {
"description": "Alternar modos atualiza as ferramentas aprovadas automaticamente.",
"title": "Alterar modo de permissão?"
},
"default": {
"behavior": "Nenhuma ferramenta é pré-aprovada automaticamente.",
"description": "Verificações normais de permissão aplicam-se.",
"title": "Padrão (perguntar antes de continuar)"
},
"helper": "Especifica como o agente lida com a autorização de uso de ferramentas",
"placeholder": "Selecionar modo de permissões",
"plan": {
"behavior": "Ferramentas somente leitura. A execução está desabilitada.",
"description": "Claude só pode usar ferramentas de leitura e apresenta um plano antes da execução.",
"title": "Modo de planejamento (em breve)"
},
"title": "Modo de permissões"
},
"preapproved": {
"autoBadge": "Adicionado por modo",
"autoDescription": "Esta ferramenta é aprovada automaticamente pelo modo de permissão atual.",
"empty": "Nenhuma ferramenta corresponde aos seus filtros.",
"mcpBadge": "Ferramenta MCP",
"requiresApproval": "Requer aprovação quando desativado",
"search": "Ferramentas de pesquisa",
"toggle": "Alternar {{name}}",
"warning": {
"description": "Ative apenas ferramentas em que confie. As configurações padrão do modo são destacadas automaticamente.",
"title": "Ferramentas pré-aprovadas são executadas sem revisão manual."
}
},
"review": {
"autoTools": "Auto: {{count}}",
"customTools": "Personalizado: {{count}}",
"helper": "As alterações são salvas automaticamente. Ajuste as etapas acima a qualquer momento para refinar as permissões.",
"mcp": "MCP: {{count}}",
"mode": "Modo: {{mode}}"
},
"steps": {
"mcp": {
"title": "Servidores MCP"
},
"permissionMode": {
"title": "Passo 1 · Modo de permissão"
},
"preapproved": {
"title": "Passo 2 · Ferramentas pré-aprovadas"
},
"review": {
"title": "Passo 3 · Revisão"
}
},
"tab": "Ferramentas e permissões"
},
"tools": {
"approved": "aprovado",
"caution": "Ferramentas pré-aprovadas ignoram a revisão humana. Ative apenas ferramentas confiáveis.",
"description": "Escolha quais ferramentas podem ser executadas sem aprovação manual.",
"requiresPermission": "Requer permissão quando não pré-aprovado.",
"tab": "Ferramentas pré-aprovadas",
"title": "Ferramentas pré-aprovadas",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Ordenação"
"type": {
"label": "Tipo de Agente",
"unknown": "Tipo Desconhecido"
},
"tag": {
"agent": "Agente",
"default": "Padrão",
"new": "Novo",
"system": "Sistema"
"update": {
"error": {
"failed": "Falha ao atualizar o agente"
}
},
"title": "Agente"
"warning": {
"enable_server": "Ativar o Servidor de API para usar agentes."
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "Exibição em Lista",
"showByTags": "Exibição por Etiquetas"
},
"presets": {
"add": {
"button": "Adicionar ao assistente",
"knowledge_base": {
"label": "Base de conhecimento",
"placeholder": "Selecionar base de conhecimento"
},
"name": {
"label": "Nome",
"placeholder": "Inserir nome"
},
"prompt": {
"label": "Prompt",
"placeholder": "Inserir prompt",
"variables": {
"tip": {
"content": "{{date}}:\tData\n{{time}}:\tHora\n{{datetime}}:\tData e hora\n{{system}}:\tSistema operacional\n{{arch}}:\tArquitetura CPU\n{{language}}:\tIdioma\n{{model_name}}:\tNome do modelo\n{{username}}:\tNome de utilizador",
"title": "Variáveis disponíveis"
}
}
},
"title": "Criar assistente",
"unsaved_changes_warning": "Tens alterações não guardadas. Tens a certeza de que queres fechar?"
},
"delete": {
"popup": {
"content": "Tens a certeza de que queres eliminar este assistente?"
}
},
"edit": {
"model": {
"select": {
"title": "Selecionar modelo"
}
},
"title": "Editar assistente"
},
"export": {
"agent": "Exportar assistente"
},
"import": {
"button": "Importar",
"error": {
"fetch_failed": "Falha ao obter dados do URL",
"invalid_format": "Formato de assistente inválido: campos obrigatórios em falta",
"url_required": "Por favor insere um URL"
},
"file_filter": "Ficheiros JSON",
"select_file": "Selecionar ficheiro",
"title": "Importar do exterior",
"type": {
"file": "Ficheiro",
"url": "URL"
},
"url_placeholder": "Inserir URL JSON"
},
"manage": {
"title": "Gerir assistentes"
},
"my_agents": "Os meus assistentes",
"search": {
"no_results": "Não foram encontrados assistentes relacionados"
},
"settings": {
"title": "Configuração de assistentes"
},
"sorting": {
"title": "Ordenar"
},
"tag": {
"agent": "Assistente",
"default": "Padrão",
"new": "Novo",
"system": "Sistema"
},
"title": "Biblioteca de assistentes"
},
"save": {
"success": "Salvo com Sucesso",
"title": "Salvar para Agente Inteligente"
@@ -334,7 +532,7 @@
"new_topic": "Novo tópico {{Command}}",
"pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Digite a mensagem aqui, pressione {{key}} para enviar",
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",
"send": "Enviar",
"settings": "Configurações",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Adicionar",
"add_success": "Adicionado com sucesso",
"advanced_settings": "Configurações Avançadas",
"agent_one": "Agente",
"agent_other": "Agentes",
"and": "e",
"assistant": "Agente Inteligente",
"assistant_one": "assistente",
"assistant_other": "assistente",
"avatar": "Avatar",
"back": "Voltar",
"browse": "Navegar",
@@ -765,6 +968,8 @@
"default": "Padrão",
"delete": "Excluir",
"delete_confirm": "Tem certeza de que deseja excluir?",
"delete_failed": "Falha ao excluir",
"delete_success": "Excluído com sucesso",
"description": "Descrição",
"detail": "detalhes",
"disabled": "Desativado",
@@ -774,6 +979,10 @@
"edit": "Editar",
"enabled": "Ativado",
"error": "错误",
"errors": {
"create_message": "Falha ao criar mensagem",
"validation": "Falha na verificação"
},
"expand": "Expandir",
"file": {
"not_supported": "Tipo de arquivo não suportado {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Ir para configurações",
"i_know": "Entendi",
"inspect": "Verificar",
"invalid_value": "Valor inválido",
"knowledge_base": "Base de Conhecimento",
"language": "Língua",
"loading": "Carregando...",
@@ -795,6 +1005,11 @@
"none": "Nenhum",
"open": "Abrir",
"paste": "Colar",
"placeholders": {
"select": {
"model": "Selecionar modelo"
}
},
"preview": "Pré-visualização",
"prompt": "Prompt",
"provider": "Fornecedor",
@@ -807,6 +1022,7 @@
"saved": "Guardado",
"search": "Pesquisar",
"select": "Selecionar",
"selected": "Selecionado",
"selectedItems": "{{count}} itens selecionados",
"selectedMessages": "{{count}} mensagens selecionadas",
"settings": "Configurações",
@@ -821,6 +1037,9 @@
"success": "Sucesso",
"swap": "Trocar",
"topics": "Tópicos",
"unknown": "Desconhecido",
"unnamed": "Sem nome",
"update_success": "Atualização bem-sucedida",
"upload_files": "Carregar arquivo",
"warning": "Aviso",
"you": "Você"
@@ -893,6 +1112,7 @@
"modelType": "Tipo de modelo",
"name": "Nome do erro",
"no_api_key": "A chave da API não foi configurada",
"no_response": "Sem resposta",
"originalError": "Erro original",
"originalMessage": "Mensagem original",
"parameter": "parâmetro",
@@ -958,6 +1178,9 @@
},
"document": "Documento",
"edit": "Editar",
"error": {
"open_path": "Não foi possível abrir o caminho: {{path}}"
},
"file": "Arquivo",
"image": "Imagem",
"name": "Nome do Arquivo",
@@ -3795,6 +4018,10 @@
"start_auth": "Iniciar autorização",
"submit_code": "Concluir login"
},
"anthropic_api_host": "Endereço da API Anthropic",
"anthropic_api_host_preview": "Pré-visualização Anthropic: {{url}}",
"anthropic_api_host_tip": "Preencher apenas se o fornecedor oferecer um endereço compatível com Anthropic. Terminar com / ignora o v1 adicionado automaticamente, terminar com # força o uso do endereço original.",
"anthropic_api_host_tooltip": "Preencher apenas quando o fornecedor fornece um endereço base compatível com Claude.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "Endereço API",
"api_host_preview": "Pré-visualização: {{url}}",
"api_host_tooltip": "Substituir apenas quando o fornecedor necessita de um endereço compatível com OpenAI personalizado.",
"api_key": {
"label": "Chave API",
"tip": "Use vírgula para separar várias chaves"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Agentes",
"apps": "Miniaplicativos",
"code": "Código",
"files": "Arquivos",
@@ -4257,6 +4485,7 @@
"notes": "Notas",
"paintings": "Pinturas",
"settings": "Configurações",
"store": "Biblioteca de assistentes",
"translate": "Traduzir"
},
"trace": {
+293 -64
View File
@@ -1,80 +1,201 @@
{
"agents": {
"agent": {
"add": {
"button": "Добавить в ассистента",
"knowledge_base": {
"label": "База знаний",
"placeholder": "Выберите базу знаний"
"error": {
"failed": "Не удалось добавить агента",
"invalid_agent": "Недействительный агент"
},
"name": {
"label": "Имя",
"placeholder": "Введите имя"
},
"prompt": {
"label": "Промпт",
"placeholder": "Введите промпт",
"variables": {
"tip": {
"content": "{{date}}:\tДата\n{{time}}:\tВремя\n{{datetime}}:\tДата и время\n{{system}}:\tОперационная система\n{{arch}}:\tАрхитектура процессора\n{{language}}:\tЯзык\n{{model_name}}:\tНазвание модели\n{{username}}:\tИмя пользователя",
"title": "Доступные переменные"
}
}
},
"title": "Создать агента",
"unsaved_changes_warning": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
},
"delete": {
"popup": {
"content": "Вы уверены, что хотите удалить этого агента?"
"title": "Добавить агента",
"type": {
"placeholder": "Выбор типа агента"
}
},
"delete": {
"content": "Удаление этого агента приведёт к принудительному завершению и удалению всех сессий, связанных с ним. Вы уверены?",
"error": {
"failed": "Не удалось удалить агента"
},
"title": "Удалить агента"
},
"edit": {
"model": {
"select": {
"title": "Выбрать модель"
"title": "Редактировать агент"
},
"get": {
"error": {
"failed": "Не удалось получить агента."
}
},
"list": {
"error": {
"failed": "Не удалось получить список агентов."
}
},
"session": {
"accessible_paths": {
"add": "Добавить каталог",
"duplicate": "Этот каталог уже включён.",
"empty": "Выберите хотя бы один каталог, к которому агент имеет доступ.",
"error": {
"at_least_one": "Пожалуйста, выберите хотя бы один доступный каталог."
},
"label": "Доступные директории",
"select_failed": "Не удалось выбрать каталог."
},
"add": {
"title": "Добавить сеанс"
},
"allowed_tools": {
"empty": "Для этого агента нет доступных инструментов.",
"helper": "Выберите инструменты с предварительным допуском. Неотмеченные инструменты потребуют подтверждения перед использованием.",
"label": "Предварительно одобренные инструменты",
"placeholder": "Выберите предварительно одобренные инструменты"
},
"create": {
"error": {
"failed": "Не удалось добавить сеанс"
}
},
"title": "Редактировать агента"
},
"export": {
"agent": "Экспорт агента"
},
"import": {
"button": "Импорт",
"error": {
"fetch_failed": "Не удалось получить данные по URL",
"invalid_format": "Неверный формат агента: отсутствуют обязательные поля",
"url_required": "Пожалуйста, введите URL"
"delete": {
"content": "Вы уверены, что хотите удалить этот сеанс?",
"error": {
"failed": "Не удалось удалить сеанс",
"last": "Должен быть сохранён хотя бы один сеанс"
},
"title": "Удалить сеанс"
},
"file_filter": "JSON файлы",
"select_file": "Выбрать файл",
"title": "Импорт из внешнего источника",
"type": {
"file": "Файл",
"url": "URL"
"edit": {
"title": "Сессия редактирования"
},
"url_placeholder": "Введите URL JSON"
},
"manage": {
"title": "Редактировать агентов"
},
"my_agents": "Мои агенты",
"search": {
"no_results": "Результаты не найдены"
"get": {
"error": {
"failed": "Не удалось получить сеанс"
}
},
"label_one": "Сессия",
"label_other": "Сессии",
"update": {
"error": {
"failed": "Не удалось обновить сеанс"
}
}
},
"settings": {
"title": "Настройки агента"
"advance": {
"maxTurns": {
"description": "Установить количество циклов запрос/ответ, выполняемых автоматически через прокси.",
"helper": "Чем выше значение, тем дольше может работать автономно; чем ниже значение, тем легче контролировать.",
"label": "Максимальное количество раундов сеанса"
},
"permissionMode": {
"description": "Как агент управления обрабатывает ситуации, требующие авторизации.",
"label": "Режим разрешений",
"options": {
"acceptEdits": "Автоматически принимать правки",
"bypassPermissions": "Пропустить проверку разрешений",
"default": "По умолчанию (спросить перед продолжением)",
"plan": "Режим планирования (требуется утверждение плана)"
},
"placeholder": "Выбрать режим разрешений"
},
"title": "Расширенные настройки"
},
"essential": "Основные настройки",
"prompt": "Настройки подсказки",
"tooling": {
"mcp": {
"description": "Подключите серверы MCP, чтобы разблокировать дополнительные инструменты, которые вы можете одобрить выше.",
"empty": "Серверы MCP не обнаружены. Добавьте один на странице настроек MCP.",
"manageHint": "Нужна расширенная настройка? Перейдите в Настройки → Серверы MCP.",
"toggle": "Переключить {{name}}"
},
"permissionMode": {
"acceptEdits": {
"behavior": "Предварительно одобряет доверенные инструменты файловой системы, чтобы изменения выполнялись немедленно.",
"description": "Редактирование файлов и операции с файловой системой автоматически одобряются.",
"title": "Автоматически принимать изменения файлов"
},
"bypassPermissions": {
"behavior": "Каждый инструмент автоматически предварительно одобрен.",
"description": "Все запросы разрешений пропускаются — используйте с осторожностью.",
"title": "Обход проверки разрешений",
"warning": "Использовать с осторожностью — все инструменты будут запущены без запроса подтверждения."
},
"confirmChange": {
"description": "Переключение режимов обновляет автоматически одобренные инструменты.",
"title": "Изменить режим разрешений?"
},
"default": {
"behavior": "Никакие инструменты не предварительно одобряются автоматически.",
"description": "Применяются обычные проверки разрешений.",
"title": "По умолчанию (спросить перед продолжением)"
},
"helper": "Определяет, как агент обрабатывает авторизацию использования инструментов",
"placeholder": "Выбрать режим разрешений",
"plan": {
"behavior": "Только инструменты чтения. Выполнение отключено.",
"description": "Claude может использовать только инструменты только для чтения и представляет план перед выполнением.",
"title": "Режим планирования (скоро появится)"
},
"title": "Режим разрешений"
},
"preapproved": {
"autoBadge": "Добавлено режимом",
"autoDescription": "Этот инструмент автоматически одобрен текущим режимом разрешений.",
"empty": "Нет инструментов, соответствующих вашим фильтрам.",
"mcpBadge": "Инструмент MCP",
"requiresApproval": "Требует одобрения при отключении",
"search": "Инструменты поиска",
"toggle": "Переключить {{name}}",
"warning": {
"description": "Включайте только те инструменты, которым доверяете. Настройки по умолчанию выделяются автоматически.",
"title": "Предварительно одобренные инструменты запускаются без ручной проверки."
}
},
"review": {
"autoTools": "Авто: {{count}}",
"customTools": "Пользовательские: {{count}}",
"helper": "Изменения сохраняются автоматически. В любое время можно скорректировать шаги выше, чтобы уточнить разрешения.",
"mcp": "MCP: {{count}}",
"mode": "Режим: {{mode}}"
},
"steps": {
"mcp": {
"title": "Серверы MCP"
},
"permissionMode": {
"title": "Шаг 1 · Режим разрешений"
},
"preapproved": {
"title": "Шаг 2 · Предварительно одобренные инструменты"
},
"review": {
"title": "Шаг 3 · Обзор"
}
},
"tab": "Инструменты и разрешения"
},
"tools": {
"approved": "одобрено",
"caution": "Предварительно одобренные инструменты обходят проверку человеком. Включайте только доверенные инструменты.",
"description": "Выберите, какие инструменты могут запускаться без ручного подтверждения.",
"requiresPermission": "Требуется разрешение, если не предварительно одобрено.",
"tab": "Предварительно одобренные инструменты",
"title": "Предварительно одобренные инструменты",
"toggle": "{{defaultValue}}"
}
},
"sorting": {
"title": "Сортировка"
"type": {
"label": "Тип агента",
"unknown": "Неизвестный тип"
},
"tag": {
"agent": "Агент",
"default": "По умолчанию",
"new": "Новый",
"system": "Система"
"update": {
"error": {
"failed": "Не удалось обновить агента"
}
},
"title": "Агенты"
"warning": {
"enable_server": "Разрешить серверу API использовать агентов."
}
},
"apiServer": {
"actions": {
@@ -155,6 +276,83 @@
"showByList": "Список",
"showByTags": "По тегам"
},
"presets": {
"add": {
"button": "Добавить в помощник",
"knowledge_base": {
"label": "База знаний",
"placeholder": "Выбрать базу знаний"
},
"name": {
"label": "Имя",
"placeholder": "Введите имя"
},
"prompt": {
"label": "Промпт",
"placeholder": "Введите промпт",
"variables": {
"tip": {
"content": "{{date}}:\tДата\n{{time}}:\tВремя\n{{datetime}}:\tДата и время\n{{system}}:\tОперационная система\n{{arch}}:\tАрхитектура CPU\n{{language}}:\tЯзык\n{{model_name}}:\tИмя модели\n{{username}}:\tИмя пользователя",
"title": "Доступные переменные"
}
}
},
"title": "Создать помощника",
"unsaved_changes_warning": "У вас есть несохранённые изменения. Вы уверены, что хотите закрыть?"
},
"delete": {
"popup": {
"content": "Вы уверены, что хотите удалить этого помощника?"
}
},
"edit": {
"model": {
"select": {
"title": "Выбрать модель"
}
},
"title": "Редактировать помощника"
},
"export": {
"agent": "Экспортировать помощника"
},
"import": {
"button": "Импортировать",
"error": {
"fetch_failed": "Ошибка получения данных с URL",
"invalid_format": "Неверный формат помощника: отсутствуют обязательные поля",
"url_required": "Пожалуйста, введите URL"
},
"file_filter": "Файлы JSON",
"select_file": "Выбрать файл",
"title": "Импорт из внешнего источника",
"type": {
"file": "Файл",
"url": "URL"
},
"url_placeholder": "Введите JSON URL"
},
"manage": {
"title": "Управление помощниками"
},
"my_agents": "Мои помощники",
"search": {
"no_results": "Не найдено соответствующих помощников"
},
"settings": {
"title": "Конфигурация помощников"
},
"sorting": {
"title": "Сортировка"
},
"tag": {
"agent": "Помощник",
"default": "По умолчанию",
"new": "Новый",
"system": "Система"
},
"title": "Библиотека помощников"
},
"save": {
"success": "Успешно сохранено",
"title": "Сохранить в агента"
@@ -334,7 +532,7 @@
"new_topic": "Новый топик {{Command}}",
"pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Введите сообщение здесь, нажмите {{key}}, чтобы отправить",
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",
"send": "Отправить",
"settings": "Настройки",
"thinking": {
@@ -746,9 +944,14 @@
},
"common": {
"add": "Добавить",
"add_success": "Успешно добавлено",
"advanced_settings": "Дополнительные настройки",
"agent_one": "Агент",
"agent_other": "Агенты",
"and": "и",
"assistant": "Ассистент",
"assistant_one": "Помощник",
"assistant_other": "ассистент",
"avatar": "Аватар",
"back": "Назад",
"browse": "Обзор",
@@ -765,6 +968,8 @@
"default": "По умолчанию",
"delete": "Удалить",
"delete_confirm": "Вы уверены, что хотите удалить?",
"delete_failed": "Не удалось удалить",
"delete_success": "Удаление выполнено успешно",
"description": "Описание",
"detail": "Подробности",
"disabled": "Отключено",
@@ -774,6 +979,10 @@
"edit": "Редактировать",
"enabled": "Включено",
"error": "ошибка",
"errors": {
"create_message": "Не удалось создать сообщение",
"validation": "Ошибка проверки"
},
"expand": "Развернуть",
"file": {
"not_supported": "Неподдерживаемый тип файла {{type}}"
@@ -784,6 +993,7 @@
"go_to_settings": "Перейти в настройки",
"i_know": "Я понял",
"inspect": "Осмотреть",
"invalid_value": "недопустимое значение",
"knowledge_base": "База знаний",
"language": "Язык",
"loading": "Загрузка...",
@@ -795,6 +1005,11 @@
"none": "без",
"open": "Открыть",
"paste": "Вставить",
"placeholders": {
"select": {
"model": "Выбор модели"
}
},
"preview": "Предварительный просмотр",
"prompt": "Промпт",
"provider": "Провайдер",
@@ -807,6 +1022,7 @@
"saved": "Сохранено",
"search": "Поиск",
"select": "Выбрать",
"selected": "Выбрано",
"selectedItems": "Выбрано {{count}} элементов",
"selectedMessages": "Выбрано {{count}} сообщений",
"settings": "Настройки",
@@ -821,6 +1037,9 @@
"success": "Успешно",
"swap": "Поменять местами",
"topics": "Топики",
"unknown": "Неизвестно",
"unnamed": "Без имени",
"update_success": "Обновление выполнено успешно",
"upload_files": "Загрузить файл",
"warning": "Предупреждение",
"you": "Вы"
@@ -893,6 +1112,7 @@
"modelType": "Тип модели",
"name": "Название ошибки",
"no_api_key": "Ключ API не настроен",
"no_response": "Нет ответа",
"originalError": "Исходная ошибка",
"originalMessage": "исходное сообщение",
"parameter": "параметр",
@@ -958,6 +1178,9 @@
},
"document": "Документ",
"edit": "Редактировать",
"error": {
"open_path": "Не удалось открыть путь: {{path}}"
},
"file": "Файл",
"image": "Изображение",
"name": "Имя",
@@ -3795,6 +4018,10 @@
"start_auth": "Начать авторизацию",
"submit_code": "Завершить вход"
},
"anthropic_api_host": "Адрес API Anthropic",
"anthropic_api_host_preview": "Предпросмотр Anthropic: {{url}}",
"anthropic_api_host_tip": "Заполняйте только если провайдер предоставляет совместимый с Anthropic адрес. Окончание на / игнорирует автоматически добавляемое v1, окончание на # принудительно использует оригинальный адрес.",
"anthropic_api_host_tooltip": "Заполняйте только когда провайдер предоставляет базовый адрес, совместимый с Claude.",
"api": {
"key": {
"check": {
@@ -3842,6 +4069,8 @@
}
},
"api_host": "Хост API",
"api_host_preview": "Предпросмотр: {{url}}",
"api_host_tooltip": "Переопределяйте только когда провайдер требует пользовательский адрес, совместимый с OpenAI.",
"api_key": {
"label": "Ключ API",
"tip": "Несколько ключей, разделенных запятыми или пробелами"
@@ -4245,7 +4474,6 @@
}
},
"title": {
"agents": "Агенты",
"apps": "Приложения",
"code": "Code",
"files": "Файлы",
@@ -4257,6 +4485,7 @@
"notes": "заметки",
"paintings": "Рисунки",
"settings": "Настройки",
"store": "Библиотека помощников",
"translate": "Перевод"
},
"trace": {
@@ -15,6 +15,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setIsBunInstalled } from '@renderer/store/mcp'
import type { EndpointType, Model } from '@renderer/types'
import { getClaudeSupportedProviders } from '@renderer/utils/provider'
import type { TerminalConfig } from '@shared/config/constant'
import { codeTools, terminalApps } from '@shared/config/constant'
import { Alert, Checkbox, Input, Popover, Select, Space } from 'antd'
@@ -30,7 +31,6 @@ import {
CLI_TOOL_PROVIDER_MAP,
CLI_TOOLS,
generateToolEnvironment,
getClaudeSupportedProviders,
OPENAI_CODEX_SUPPORTED_PROVIDERS,
parseEnvironmentVariables
} from '.'
-4
View File
@@ -169,8 +169,4 @@ export const generateToolEnvironment = ({
return env
}
export const getClaudeSupportedProviders = (providers: Provider[]) => {
return providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id))
}
export { default } from './CodeToolsPage'
+89 -18
View File
@@ -1,5 +1,6 @@
import { ColFlex, RowFlex } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { Alert } from '@heroui/react'
import { loggerService } from '@logger'
import type { ContentSearchRef } from '@renderer/components/ContentSearch'
import { ContentSearch } from '@renderer/components/ContentSearch'
@@ -8,6 +9,7 @@ import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
@@ -18,13 +20,15 @@ import { classNames } from '@renderer/utils'
import { debounce } from 'lodash'
import { AnimatePresence, motion } from 'motion/react'
import type { FC } from 'react'
import React, { useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ChatNavbar from './ChatNavbar'
import AgentSessionInputbar from './Inputbar/AgentSessionInputbar'
import Inputbar from './Inputbar/Inputbar'
import AgentSessionMessages from './Messages/AgentSessionMessages'
import ChatNavigation from './Messages/ChatNavigation'
import Messages from './Messages/Messages'
import Tabs from './Tabs'
@@ -44,10 +48,13 @@ const Chat: FC<Props> = (props) => {
const [topicPosition] = usePreference('topic.position')
const [messageStyle] = usePreference('chat.message.style')
const [messageNavigation] = usePreference('chat.message.navigation_mode')
const [apiServerEnabled] = usePreference('feature.csaas.enabled')
const { showTopics } = useShowTopics()
const { isMultiSelectMode } = useChatContext(props.activeTopic)
const { isTopNavbar } = useNavbarPosition()
const chatMaxWidth = useChatMaxWidth()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionId } = chat
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@@ -140,6 +147,56 @@ const Chat: FC<Props> = (props) => {
? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)'
: 'calc(100vh - var(--navbar-height))'
const SessionMessages = useMemo(() => {
if (activeAgentId === null) {
return () => <div> Active Agent ID is invalid.</div>
}
const sessionId = activeSessionId[activeAgentId]
if (!sessionId) {
return () => <div> Active Session ID is invalid.</div>
}
if (!apiServerEnabled) {
return () => (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
}
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
}, [activeAgentId, activeSessionId, apiServerEnabled, t])
const SessionInputBar = useMemo(() => {
if (activeAgentId === null) {
return () => <div> Active Agent ID is invalid.</div>
}
const sessionId = activeSessionId[activeAgentId]
if (!sessionId) {
return () => <div> Active Session ID is invalid.</div>
}
return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={sessionId} />
}, [activeAgentId, activeSessionId])
// TODO: more info
const AgentInvalid = useCallback(() => {
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<Alert color="warning" title="Select an agent" />
</div>
</div>
)
}, [])
// TODO: more info
const SessionInvalid = useCallback(() => {
return (
<div className="flex h-full w-full items-center justify-center">
<div>
<Alert color="warning" title="Create a session" />
</div>
</div>
)
}, [])
return (
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
{isTopNavbar && (
@@ -158,23 +215,37 @@ const Chat: FC<Props> = (props) => {
className="flex-1 justify-between"
style={{ maxWidth: chatMaxWidth, height: mainHeight }}>
<QuickPanelProvider>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
{activeTopicOrSession === 'topic' && (
<>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</>
)}
{activeTopicOrSession === 'session' && !activeAgentId && <AgentInvalid />}
{activeTopicOrSession === 'session' && activeAgentId && !activeSessionId[activeAgentId] && (
<SessionInvalid />
)}
{activeTopicOrSession === 'session' && activeAgentId && activeSessionId[activeAgentId] && (
<>
<SessionMessages />
<SessionInputBar />
</>
)}
{isMultiSelectMode && <MultiSelectActionPopup topic={props.activeTopic} />}
</QuickPanelProvider>
</Main>
+129 -5
View File
@@ -1,21 +1,33 @@
import { RowFlex } from '@cherrystudio/ui'
import { Tooltip } from '@cherrystudio/ui'
import { usePreference } from '@data/hooks/usePreference'
import { BreadcrumbItem, Breadcrumbs, Chip, cn } from '@heroui/react'
import { NavbarHeader } from '@renderer/components/app/Navbar'
import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { permissionModeCards } from '@renderer/constants/permissionModes'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { modelGenerating } from '@renderer/hooks/useModel'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { Assistant, Topic } from '@renderer/types'
import type { ApiModel, Assistant, PermissionMode, Topic } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { t } from 'i18next'
import { Menu, PanelLeftClose, PanelRightClose, Search } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import type { FC, ReactNode } from 'react'
import styled from 'styled-components'
import { AgentSettingsPopup } from '../settings/AgentSettings'
import { AgentLabel } from '../settings/AgentSettings/shared'
import AssistantsDrawer from './components/AssistantsDrawer'
import SelectAgentModelButton from './components/SelectAgentModelButton'
import SelectModelButton from './components/SelectModelButton'
import UpdateAppButton from './components/UpdateAppButton'
@@ -35,6 +47,11 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const sessionId = activeAgentId ? (chat.activeSessionId[activeAgentId] ?? null) : null
const { agent } = useAgent(activeAgentId)
const { updateModel } = useUpdateAgent()
useShortcut('toggle_show_assistants', toggleShowAssistants)
@@ -64,9 +81,17 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
})
}
const handleUpdateModel = useCallback(
async (model: ApiModel) => {
if (!agent) return
return updateModel(agent.id, model.id, { showSuccessToast: false })
},
[agent, updateModel]
)
return (
<NavbarHeader className="home-navbar">
<RowFlex className="items-center">
<div className="flex min-w-0 flex-1 shrink items-center overflow-auto">
{showAssistants && (
<Tooltip placement="bottom" content={t('navbar.hide_sidebar')} delay={800}>
<NavbarIcon onClick={toggleShowAssistants}>
@@ -94,8 +119,39 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
</motion.div>
)}
</AnimatePresence>
<SelectModelButton assistant={assistant} />
</RowFlex>
{activeTopicOrSession === 'topic' && <SelectModelButton assistant={assistant} />}
{activeTopicOrSession === 'session' && agent && (
<HorizontalScrollContainer>
<Breadcrumbs
classNames={{
base: 'flex',
list: 'flex-nowrap'
}}>
<BreadcrumbItem
onPress={() => AgentSettingsPopup.show({ agentId: agent.id })}
classNames={{
base: 'self-stretch',
item: 'h-full'
}}>
<Chip size="md" variant="light" className="h-full transition-background hover:bg-foreground-100">
<AgentLabel
agent={agent}
classNames={{ name: 'max-w-50 font-bold text-xs', avatar: 'h-4.5 w-4.5', container: 'gap-1.5' }}
/>
</Chip>
</BreadcrumbItem>
<BreadcrumbItem>
<SelectAgentModelButton agent={agent} onSelect={handleUpdateModel} />
</BreadcrumbItem>
{activeAgentId && sessionId && (
<BreadcrumbItem>
<SessionWorkspaceMeta agentId={activeAgentId} sessionId={sessionId} />
</BreadcrumbItem>
)}
</Breadcrumbs>
</HorizontalScrollContainer>
)}
</div>
<RowFlex className="items-center gap-2">
<UpdateAppButton />
<Tooltip placement="bottom" content={t('navbar.expand')} delay={800}>
@@ -127,6 +183,74 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
)
}
const SessionWorkspaceMeta: FC<{ agentId: string; sessionId: string }> = ({ agentId, sessionId }) => {
const { agent } = useAgent(agentId)
const { session } = useSession(agentId, sessionId)
if (!session || !agent) {
return null
}
const firstAccessiblePath = session.accessible_paths?.[0]
const permissionMode = (session.configuration?.permission_mode ?? 'default') as PermissionMode
const permissionModeCard = permissionModeCards.find((card) => card.mode === permissionMode)
const permissionModeLabel = permissionModeCard
? t(permissionModeCard.titleKey, permissionModeCard.titleFallback)
: permissionMode
const infoItems: ReactNode[] = []
const InfoTag = ({
text,
className,
onClick
}: {
text: string
className?: string
classNames?: {}
onClick?: (e: React.MouseEvent) => void
}) => (
<div
className={cn(
'rounded-medium border border-default-200 px-2 py-1 text-foreground-500 text-xs dark:text-foreground-400',
onClick !== undefined ? 'cursor-pointer' : undefined,
className
)}
title={text}
onClick={onClick}>
<span className="block truncate">{text}</span>
</div>
)
// infoItems.push(<InfoTag key="name" text={agent.name ?? ''} className="max-w-60" />)
if (firstAccessiblePath) {
infoItems.push(
<InfoTag
key="path"
text={firstAccessiblePath}
className="max-w-60 transition-colors hover:border-primary hover:text-primary"
onClick={() => {
window.api.file
.openPath(firstAccessiblePath)
.catch((e) =>
window.toast.error(
formatErrorMessageWithPrefix(e, t('files.error.open_path', { path: firstAccessiblePath }))
)
)
}}
/>
)
}
infoItems.push(<InfoTag key="permission-mode" text={permissionModeLabel} className="max-w-50" />)
if (infoItems.length === 0) {
return null
}
return <div className="ml-2 flex items-center gap-2">{infoItems}</div>
}
export const NavbarIcon = styled.div`
-webkit-app-region: none;
border-radius: 8px;
+32
View File
@@ -1,11 +1,14 @@
import { usePreference } from '@data/hooks/usePreference'
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useNavbarPosition } from '@renderer/hooks/useNavbar'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import NavigationService from '@renderer/services/NavigationService'
import { newMessagesActions } from '@renderer/store/newMessage'
import { setActiveAgentId, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant, Topic } from '@renderer/types'
import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SECOND_MIN_WINDOW_WIDTH } from '@shared/config/constant'
import { AnimatePresence, motion } from 'motion/react'
@@ -26,6 +29,9 @@ const HomePage: FC = () => {
const navigate = useNavigate()
const { isLeftNavbar } = useNavbarPosition()
// Initialize agent session hook
useAgentSessionInitializer()
const location = useLocation()
const state = location.state
@@ -35,6 +41,8 @@ const HomePage: FC = () => {
const [showTopics] = usePreference('topic.tab.show')
const [topicPosition] = usePreference('topic.position')
const dispatch = useDispatch()
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
_activeAssistant = activeAssistant
@@ -56,6 +64,7 @@ const HomePage: FC = () => {
startTransition(() => {
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
dispatch(newMessagesActions.setTopicFulfilled({ topicId: newTopic.id, fulfilled: false }))
dispatch(setActiveTopicOrSessionAction('topic'))
})
},
[_setActiveTopic, dispatch]
@@ -93,6 +102,29 @@ const HomePage: FC = () => {
}
}, [showAssistants, showTopics, topicPosition])
useEffect(() => {
if (activeTopicOrSession === 'session') {
setActiveAssistant({
id: 'fake',
name: '',
prompt: '',
topics: [
{
id: 'fake',
assistantId: 'fake',
name: 'fake',
createdAt: '',
updatedAt: '',
messages: []
} as unknown as Topic
],
type: 'chat'
})
} else if (activeTopicOrSession === 'topic') {
dispatch(setActiveAgentId('fake'))
}
}, [activeTopicOrSession, dispatch, setActiveAssistant])
return (
<Container id="home-page">
{isLeftNavbar && (
@@ -0,0 +1,366 @@
import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSession } from '@renderer/hooks/agents/useSession'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
import { getModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Message, Model, Topic } from '@renderer/types'
import { MessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
import { CirclePause } from 'lucide-react'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuid } from 'uuid'
import NarrowLayout from '../Messages/NarrowLayout'
import SendMessageButton from './SendMessageButton'
const logger = loggerService.withContext('Inputbar')
type Props = {
agentId: string
sessionId: string
}
const _text = ''
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { session } = useSession(agentId, sessionId)
const { agent } = useAgent(agentId)
const { apiServer } = useSettings()
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
const { t } = useTranslation()
const containerRef = useRef(null)
const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch()
const sessionTopicId = buildAgentSessionTopicId(sessionId)
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
const focusTextarea = useCallback(() => {
textareaRef.current?.focus()
}, [])
const inputEmpty = isEmpty(text)
const sendDisabled = inputEmpty || !apiServer.enabled
const streamingAskIds = useMemo(() => {
if (!topicMessages) {
return []
}
const askIdSet = new Set<string>()
for (const message of topicMessages) {
if (!message) continue
if (message.status === 'processing' || message.status === 'pending') {
if (message.askId) {
askIdSet.add(message.askId)
} else if (message.id) {
askIdSet.add(message.id)
}
}
}
return Array.from(askIdSet)
}, [topicMessages])
const canAbort = loading && streamingAskIds.length > 0
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
//to check if the SendMessage key is pressed
//other keys should be ignored
const isEnterPressed = event.key === 'Enter' && !event.nativeEvent.isComposing
if (isEnterPressed) {
// 1) 优先判断是否为“发送”(当前仅支持纯 Enter 发送;其余 Enter 组合键均换行)
if (isSendMessageKeyPressed(event, sendMessageShortcut)) {
sendMessage()
return event.preventDefault()
}
// 2) 不再基于 quickPanel.isVisible 主动拦截。
// 纯 Enter 的处理权交由 QuickPanel 的全局捕获(其只在纯 Enter 时拦截),
// 其它带修饰键的 Enter 则由输入框处理为换行。
if (event.shiftKey) {
return
}
event.preventDefault()
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const start = textArea.selectionStart
const end = textArea.selectionEnd
const text = textArea.value
const newText = text.substring(0, start) + '\n' + text.substring(end)
// update text by setState, not directly modify textarea.value
setText(newText)
// set cursor position in the next render cycle
setTimeoutTimer(
'handleKeyDown',
() => {
textArea.selectionStart = textArea.selectionEnd = start + 1
},
0
)
}
}
}
const abortAgentSession = useCallback(async () => {
if (!streamingAskIds.length) {
logger.debug('No active agent session streams to abort', { sessionTopicId })
return
}
logger.info('Aborting agent session message generation', {
sessionTopicId,
askIds: streamingAskIds
})
for (const askId of streamingAskIds) {
abortCompletion(askId)
}
pauseTrace(sessionTopicId)
dispatch(newMessagesActions.setTopicLoading({ topicId: sessionTopicId, loading: false }))
}, [dispatch, sessionTopicId, streamingAskIds])
const sendMessage = useCallback(async () => {
if (sendDisabled) {
return
}
logger.info('Starting to send message')
try {
const userMessageId = uuid()
const mainBlock = createMainTextBlock(userMessageId, text, {
status: MessageBlockStatus.SUCCESS
})
const userMessageBlocks: MessageBlock[] = [mainBlock]
// Extract the actual model ID from session.model (format: "provider:modelId")
const [providerId, actualModelId] = agent?.model.split(':') ?? [undefined, undefined]
// Try to find the actual model from providers
const actualModel = actualModelId ? getModel(actualModelId, providerId) : undefined
const model: Model | undefined = actualModel
? {
id: actualModel.id,
name: actualModel.name, // Use actual model name if found
provider: actualModel.provider,
group: actualModel.group
}
: undefined
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
id: userMessageId,
blocks: userMessageBlocks.map((block) => block?.id),
model,
modelId: model?.id
})
const assistantStub: Assistant = {
id: session?.agent_id ?? agentId,
name: session?.name ?? 'Agent Session',
prompt: session?.instructions ?? '',
topics: [] as Topic[],
type: 'agent-session',
model,
defaultModel: model,
tags: [],
enableWebSearch: false
}
dispatch(
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
agentId,
sessionId
})
)
setText('')
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
} catch (error) {
logger.warn('Failed to send message:', error as Error)
}
}, [
agent?.model,
agentId,
dispatch,
sendDisabled,
session?.agent_id,
session?.instructions,
session?.name,
sessionId,
sessionTopicId,
setTimeoutTimer,
text
])
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
setText(newText)
}, [])
useEffect(() => {
if (!document.querySelector('.topview-fullscreen-container')) {
focusTextarea()
}
}, [focusTextarea])
useEffect(() => {
const onFocus = () => {
if (document.activeElement?.closest('.ant-modal')) {
return
}
const lastFocusedComponent = PasteService.getLastFocusedComponent()
if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') {
focusTextarea()
}
}
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [focusTextarea])
return (
<NarrowLayout style={{ width: '100%' }}>
<Container className="inputbar">
<QuickPanelView setInputText={setText} />
<InputBarContainer
id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus')}
ref={containerRef}>
<Textarea
value={text}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })}
autoFocus
variant="borderless"
spellCheck={enableSpellCheck}
rows={2}
autoSize={{ minRows: 2, maxRows: 20 }}
ref={textareaRef}
style={{
fontSize,
minHeight: '30px'
}}
styles={{ textarea: TextareaStyle }}
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
setInputFocus(true)
// 记录当前聚焦的组件
PasteService.setLastFocusedComponent('inputbar')
if (e.target.value.length === 0) {
e.target.setSelectionRange(0, 0)
}
}}
onBlur={() => setInputFocus(false)}
/>
<div className="flex justify-end px-1">
<div className="flex items-center gap-1">
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
<ActionIconButton onClick={abortAgentSession} style={{ marginRight: -2 }}>
<CirclePause size={20} color="var(--color-error)" />
</ActionIconButton>
</Tooltip>
)}
</div>
</div>
</InputBarContainer>
</Container>
</NarrowLayout>
)
}
// Add these styled components at the bottom
const Container = styled.div`
display: flex;
flex-direction: column;
position: relative;
z-index: 2;
padding: 0 18px 18px 18px;
[navbar-position='top'] & {
padding: 0 18px 10px 18px;
}
`
const InputBarContainer = styled.div`
border: 0.5px solid var(--color-border);
transition: all 0.2s ease;
position: relative;
border-radius: 17px;
padding-top: 8px; // 为拖动手柄留出空间
background-color: var(--color-background-opacity);
&.file-dragging {
border: 2px dashed #2ecc71;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(46, 204, 113, 0.03);
border-radius: 14px;
z-index: 5;
pointer-events: none;
}
}
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '6px 15px 0px' // 减小顶部padding
}
const Textarea = styled(TextArea)`
padding: 0;
border-radius: 0;
display: flex;
resize: none !important;
overflow: auto;
width: 100%;
box-sizing: border-box;
transition: none !important;
&.ant-input {
line-height: 1.4;
}
&::-webkit-scrollbar {
width: 3px;
}
`
export default AgentSessionInputbar
@@ -230,7 +230,7 @@ const InputbarTools = ({
}
},
{
label: t('agents.edit.model.select.title'),
label: t('assistants.presets.edit.model.select.title'),
description: '',
icon: <AtSign />,
isMenu: true,
@@ -427,7 +427,7 @@ const InputbarTools = ({
},
{
key: 'mention_models',
label: t('agents.edit.model.select.title'),
label: t('assistants.presets.edit.model.select.title'),
component: (
<MentionModelsButton
ref={mentionModelsButtonRef}
@@ -239,7 +239,7 @@ const MentionModelsButton: FC<Props> = ({
triggerInfoRef.current = triggerInfo
quickPanel.open({
title: t('agents.edit.model.select.title'),
title: t('assistants.presets.edit.model.select.title'),
list: modelItems,
symbol: QuickPanelReservedSymbol.MentionModels,
multiple: true,
@@ -303,7 +303,7 @@ const MentionModelsButton: FC<Props> = ({
}))
return (
<Tooltip content={t('agents.edit.model.select.title')} closeDelay={0}>
<Tooltip content={t('assistants.presets.edit.model.select.title')} closeDelay={0}>
<ActionIconButton
onPress={handleOpenQuickPanel}
active={mentionedModels.length > 0}
@@ -0,0 +1,89 @@
import { loggerService } from '@logger'
import ContextMenu from '@renderer/components/ContextMenu'
import { useSession } from '@renderer/hooks/agents/useSession'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { getGroupedMessages } from '@renderer/services/MessagesService'
import { type Topic, TopicType } from '@renderer/types'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { memo, useMemo } from 'react'
import styled from 'styled-components'
import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import { MessagesContainer, ScrollContainer } from './shared'
const logger = loggerService.withContext('AgentSessionMessages')
type Props = {
agentId: string
sessionId: string
}
const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
const { session } = useSession(agentId, sessionId)
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
// Use the same hook as Messages.tsx for consistent behavior
const messages = useTopicMessages(sessionTopicId)
const displayMessages = useMemo(() => {
if (!messages || messages.length === 0) return []
return [...messages].reverse()
}, [messages])
const groupedMessages = useMemo(() => {
if (!displayMessages || displayMessages.length === 0) return []
return Object.entries(getGroupedMessages(displayMessages))
}, [displayMessages])
const sessionAssistantId = session?.agent_id ?? agentId
const sessionName = session?.name ?? sessionId
const sessionCreatedAt = session?.created_at ?? session?.updated_at ?? FALLBACK_TIMESTAMP
const sessionUpdatedAt = session?.updated_at ?? session?.created_at ?? FALLBACK_TIMESTAMP
const derivedTopic = useMemo<Topic>(
() => ({
id: sessionTopicId,
type: TopicType.Session,
assistantId: sessionAssistantId,
name: sessionName,
createdAt: sessionCreatedAt,
updatedAt: sessionUpdatedAt,
messages: []
}),
[sessionTopicId, sessionAssistantId, sessionName, sessionCreatedAt, sessionUpdatedAt]
)
logger.silly('Rendering agent session messages', {
sessionId,
messageCount: messages.length
})
return (
<MessagesContainer id="messages" className="messages-container">
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
<ContextMenu>
<ScrollContainer>
{groupedMessages.length > 0 ? (
groupedMessages.map(([key, groupMessages]) => (
<MessageGroup key={key} messages={groupMessages} topic={derivedTopic} />
))
) : (
<EmptyState>{session ? 'No messages yet.' : 'Loading session...'}</EmptyState>
)}
</ScrollContainer>
</ContextMenu>
</NarrowLayout>
</MessagesContainer>
)
}
const EmptyState = styled.div`
color: var(--color-text-3);
font-size: 12px;
text-align: center;
padding: 20px 0;
`
const FALLBACK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
export default memo(AgentSessionMessages)
@@ -237,7 +237,7 @@ const MessageItem: FC<Props> = ({
<HorizontalScrollContainer
classNames={{
content: cn(
'items-center',
'flex-1 items-center justify-between',
isLastMessage && messageStyle === 'plain' ? 'flex-row-reverse' : 'flex-row'
)
}}>
@@ -5,9 +5,11 @@ import UserPopup from '@renderer/components/Popups/UserPopup'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import useAvatar from '@renderer/hooks/useAvatar'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useMessageStyle } from '@renderer/hooks/useSettings'
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
import { getMessageModelId } from '@renderer/services/MessagesService'
@@ -41,6 +43,10 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic, isGro
const { theme } = useTheme()
const [userName] = usePreference('app.user.name')
const showMinappIcon = useSidebarIconShow('minapp')
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId } = chat
const { agent } = useAgent(activeAgentId)
const isAgentView = activeTopicOrSession === 'session'
const { t } = useTranslation()
const { isBubbleStyle } = useMessageStyle()
const { openMinappById } = useMinappPopup()
@@ -56,12 +62,16 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message, topic, isGro
return APP_NAME
}
if (isAgentView && message.role === 'assistant') {
return agent?.name ?? t('common.unknown')
}
if (message.role === 'assistant') {
return getModelName(model) || getMessageModelId(message) || ''
}
return userName || t('common.you')
}, [message, model, t, userName])
}, [agent?.name, isAgentView, message, model, t, userName])
const isAssistantMessage = message.role === 'assistant'
const isUserMessage = message.role === 'user'
@@ -6,8 +6,10 @@ import { loggerService } from '@logger'
import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/components/Icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { SelectModelPopup } from '@renderer/components/Popups/SelectModelPopup'
import { isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import type { MessageMenubarButtonId, MessageMenubarScope } from '@renderer/config/registry/messageMenubar'
import { DEFAULT_MESSAGE_MENUBAR_SCOPE, getMessageMenubarConfig } from '@renderer/config/registry/messageMenubar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
@@ -42,8 +44,10 @@ import {
findTranslationBlocksById,
getMainTextContent
} from '@renderer/utils/messageUtils/find'
import type { MenuProps } from 'antd'
import { Dropdown, Popconfirm } from 'antd'
import dayjs from 'dayjs'
import type { TFunction } from 'i18next'
import {
AtSign,
Check,
@@ -57,8 +61,8 @@ import {
ThumbsUp,
Upload
} from 'lucide-react'
import type { FC } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import { Fragment, memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
@@ -81,6 +85,43 @@ interface Props {
const logger = loggerService.withContext('MessageMenubar')
type MessageOperationsHandlers = ReturnType<typeof useMessageOperations>
type MessageMenubarButtonContext = {
assistant: Assistant
blockEntities: ReturnType<typeof messageBlocksSelectors.selectEntities>
confirmDeleteMessage: boolean
confirmRegenerateMessage: boolean
copied: boolean
deleteMessage: MessageOperationsHandlers['deleteMessage']
dropdownItems: MenuProps['items']
enableDeveloperMode: boolean
handleResendUserMessage: (messageUpdate?: Message) => Promise<void>
handleTraceUserMessage: () => void | Promise<void>
handleTranslate: (language: TranslateLanguage) => Promise<void>
hasTranslationBlocks: boolean
isAssistantMessage: boolean
isBubbleStyle: boolean
isGrouped?: boolean
isLastMessage: boolean
isUserMessage: boolean
message: Message
notesPath: string
onCopy: (e: React.MouseEvent) => void
onEdit: () => void | Promise<void>
onMentionModel: (e: React.MouseEvent) => void | Promise<void>
onRegenerate: (e?: React.MouseEvent) => void | Promise<void>
onUseful: (e: React.MouseEvent) => void
removeMessageBlock: MessageOperationsHandlers['removeMessageBlock']
setShowDeleteTooltip: Dispatch<SetStateAction<boolean>>
showDeleteTooltip: boolean
softHoverBg: boolean
t: TFunction
translateLanguages: TranslateLanguage[]
}
type MessageMenubarButtonRenderer = (ctx: MessageMenubarButtonContext) => ReactNode | null
const MessageMenubar: FC<Props> = (props) => {
const {
message,
@@ -235,12 +276,15 @@ const MessageMenubar: FC<Props> = (props) => {
}
}, [message])
const menubarScope: MessageMenubarScope = topic?.type ?? DEFAULT_MESSAGE_MENUBAR_SCOPE
const { buttonIds, dropdownRootAllowKeys } = getMessageMenubarConfig(menubarScope)
const isEditable = useMemo(() => {
return findMainTextBlocks(message).length > 0 // 使用 MCP Server 后会有大于一段 MatinTextBlock
}, [message])
const dropdownItems = useMemo(
() => [
const dropdownItems = useMemo(() => {
const items: MenuProps['items'] = [
...(isEditable
? [
{
@@ -360,7 +404,7 @@ const MessageMenubar: FC<Props> = (props) => {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
const title = topic.name?.replace(/\//g, '_') || 'Untitled'
const title = topic.name?.replace(/\\/g, '_') || 'Untitled'
await ObsidianExportPopup.show({ title, message, processingMethod: '1' })
}
},
@@ -383,29 +427,47 @@ const MessageMenubar: FC<Props> = (props) => {
}
].filter(Boolean)
}
],
[
isEditable,
t,
onEdit,
onNewBranch,
exportMenuOptions.plain_text,
exportMenuOptions.image,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.docx,
exportMenuOptions.notion,
exportMenuOptions.yuque,
exportMenuOptions.obsidian,
exportMenuOptions.joplin,
exportMenuOptions.siyuan,
toggleMultiSelectMode,
message,
mainTextContent,
messageContainerRef,
topic.name
]
)
].filter(Boolean)
if (!dropdownRootAllowKeys || dropdownRootAllowKeys.length === 0) {
return items
}
const allowSet = new Set(dropdownRootAllowKeys)
return items.filter((item) => {
if (!item || typeof item !== 'object') {
return false
}
if ('type' in item && item.type === 'divider') {
return false
}
if ('key' in item && item.key) {
return allowSet.has(String(item.key))
}
return false
})
}, [
dropdownRootAllowKeys,
exportMenuOptions.docx,
exportMenuOptions.image,
exportMenuOptions.joplin,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.notion,
exportMenuOptions.obsidian,
exportMenuOptions.plain_text,
exportMenuOptions.siyuan,
exportMenuOptions.yuque,
isEditable,
mainTextContent,
message,
messageContainerRef,
onEdit,
onNewBranch,
t,
toggleMultiSelectMode,
topic.name
])
const onRegenerate = async (e: React.MouseEvent | undefined) => {
e?.stopPropagation?.()
@@ -479,234 +541,56 @@ const MessageMenubar: FC<Props> = (props) => {
const showMessageTokens = !isBubbleStyle
const isUserBubbleStyleMessage = isBubbleStyle && isUserMessage
const buttonContext: MessageMenubarButtonContext = {
assistant,
blockEntities,
confirmDeleteMessage,
confirmRegenerateMessage,
copied,
deleteMessage,
dropdownItems,
enableDeveloperMode,
handleResendUserMessage,
handleTraceUserMessage,
handleTranslate,
hasTranslationBlocks,
isAssistantMessage,
isBubbleStyle,
isGrouped,
isLastMessage,
isUserMessage,
message,
notesPath,
onCopy,
onEdit,
onMentionModel,
onRegenerate,
onUseful,
removeMessageBlock,
setShowDeleteTooltip,
showDeleteTooltip,
softHoverBg,
t,
translateLanguages
}
return (
<>
{showMessageTokens && <MessageTokens message={message} />}
<MenusBar
className={classNames({ menubar: true, show: isLastMessage, 'user-bubble-style': isUserBubbleStyleMessage })}>
{message.role === 'user' &&
(confirmRegenerateMessage ? (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
onConfirm={() => handleResendUserMessage()}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
{/* FIXME: Popconfirm from antd is not compatible with Tooltip from HeroUI */}
{/* <Tooltip content={t('common.regenerate')} delay={800}> */}
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={isBubbleStyle}>
<RefreshIcon size={15} />
</ActionButton>
{/* </Tooltip> */}
</Popconfirm>
) : (
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton
className="message-action-button"
onClick={() => handleResendUserMessage()}
$softHoverBg={isBubbleStyle}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
))}
{message.role === 'user' && (
<Tooltip content={t('common.edit')} delay={800}>
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
<EditIcon size={15} />
</ActionButton>
</Tooltip>
)}
<Tooltip content={t('common.copy')} delay={800}>
<ActionButton className="message-action-button" onClick={onCopy} $softHoverBg={softHoverBg}>
{!copied && <CopyIcon size={15} />}
{copied && <Check size={15} color="var(--color-primary)" />}
</ActionButton>
</Tooltip>
{isAssistantMessage &&
(confirmRegenerateMessage ? (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
onConfirm={onRegenerate}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
{/* FIXME: Popconfirm from antd is not compatible with Tooltip from HeroUI */}
{/* <Tooltip content={t('common.regenerate')} delay={800}> */}
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<RefreshIcon size={15} />
</ActionButton>
{/* </Tooltip> */}
</Popconfirm>
) : (
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton className="message-action-button" onClick={onRegenerate} $softHoverBg={softHoverBg}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
))}
{isAssistantMessage && (
<Tooltip content={t('message.mention.title')} delay={800}>
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
<AtSign size={15} />
</ActionButton>
</Tooltip>
)}
{!isUserMessage && (
<Dropdown
menu={{
style: {
maxHeight: 250,
overflowY: 'auto',
backgroundClip: 'border-box'
},
items: [
...translateLanguages.map((item) => ({
label: item.emoji + ' ' + item.label(),
key: item.langCode,
onClick: () => handleTranslate(item)
})),
...(hasTranslationBlocks
? [
{ type: 'divider' as const },
{
label: '📋 ' + t('common.copy'),
key: 'translate-copy',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
if (translationBlocks.length > 0) {
const translationContent = translationBlocks
.map((block) => block?.content || '')
.join('\n\n')
.trim()
if (translationContent) {
navigator.clipboard.writeText(translationContent)
window.toast.success(t('translate.copied'))
} else {
window.toast.warning(t('translate.empty'))
}
}
}
},
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
.map((block) => block?.id)
if (translationBlocks.length > 0) {
translationBlocks.forEach((blockId) => {
if (blockId) removeMessageBlock(message.id, blockId)
})
window.toast.success(t('translate.closed'))
}
}
}
]
: [])
],
onClick: (e) => e.domEvent.stopPropagation()
}}
trigger={['click']}
arrow>
<Tooltip content={t('chat.translate')} delay={1200}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Languages size={15} />
</ActionButton>
</Tooltip>
</Dropdown>
)}
{isAssistantMessage && isGrouped && (
<Tooltip content={t('chat.message.useful.label')} delay={800}>
<ActionButton className="message-action-button" onClick={onUseful} $softHoverBg={softHoverBg}>
{message.useful ? (
<ThumbsUp size={17.5} fill="var(--color-primary)" strokeWidth={0} />
) : (
<ThumbsUp size={15} />
)}
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && (
<Tooltip content={t('notes.save')} delay={800}>
<ActionButton
className="message-action-button"
onClick={async (e) => {
e.stopPropagation()
const title = await getMessageTitle(message)
const markdown = await messageToMarkdown(message)
exportMessageToNotes(title, markdown, notesPath)
}}
$softHoverBg={softHoverBg}>
<NotebookPen size={15} />
</ActionButton>
</Tooltip>
)}
{confirmDeleteMessage ? (
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Tooltip content={t('common.delete')} delay={800}>
<DeleteIcon size={15} />
</Tooltip>
</ActionButton>
</Popconfirm>
) : (
<ActionButton
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
deleteMessage(message.id, message.traceId, message.model?.name)
}}
$softHoverBg={softHoverBg}>
<Tooltip
content={t('common.delete')}
delay={1000}
isOpen={showDeleteTooltip}
onOpenChange={setShowDeleteTooltip}>
<DeleteIcon size={15} />
</Tooltip>
</ActionButton>
)}
{enableDeveloperMode && message.traceId && (
<Tooltip content={t('trace.label')} delay={800}>
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
<TraceIcon size={16} className={'lucide lucide-trash'} />
</ActionButton>
</Tooltip>
)}
{!isUserMessage && (
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight">
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Menu size={19} />
</ActionButton>
</Dropdown>
)}
{buttonIds.map((buttonId) => {
const renderFn = buttonRenderers[buttonId]
if (!renderFn) {
logger.warn(`No renderer registered for MessageMenubar button id: ${buttonId}`)
return null
}
const element = renderFn(buttonContext)
if (!element) {
return null
}
return <Fragment key={buttonId}>{element}</Fragment>
})}
</MenusBar>
</>
)
@@ -754,10 +638,328 @@ const ActionButton = styled.div<{ $softHoverBg?: boolean }>`
}
`
// const ReSendButton = styled(Button)`
// position: absolute;
// top: 10px;
// left: 0;
// `
const buttonRenderers: Record<MessageMenubarButtonId, MessageMenubarButtonRenderer> = {
'user-regenerate': ({
message,
confirmRegenerateMessage,
handleResendUserMessage,
setShowDeleteTooltip,
t,
isBubbleStyle
}) => {
if (message.role !== 'user') {
return null
}
if (confirmRegenerateMessage) {
return (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
onConfirm={() => handleResendUserMessage()}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={isBubbleStyle}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
</Popconfirm>
)
}
return (
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton
className="message-action-button"
onClick={() => handleResendUserMessage()}
$softHoverBg={isBubbleStyle}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
)
},
'user-edit': ({ message, onEdit, softHoverBg, t }) => {
if (message.role !== 'user') {
return null
}
return (
<Tooltip content={t('common.edit')} delay={800}>
<ActionButton className="message-action-button" onClick={onEdit} $softHoverBg={softHoverBg}>
<EditIcon size={15} />
</ActionButton>
</Tooltip>
)
},
copy: ({ onCopy, softHoverBg, copied, t }) => (
<Tooltip content={t('common.copy')} delay={800}>
<ActionButton className="message-action-button" onClick={onCopy} $softHoverBg={softHoverBg}>
{!copied && <CopyIcon size={15} />}
{copied && <Check size={15} color="var(--color-primary)" />}
</ActionButton>
</Tooltip>
),
'assistant-regenerate': ({
isAssistantMessage,
confirmRegenerateMessage,
onRegenerate,
setShowDeleteTooltip,
softHoverBg,
t
}) => {
if (!isAssistantMessage) {
return null
}
if (confirmRegenerateMessage) {
return (
<Popconfirm
title={t('message.regenerate.confirm')}
okButtonProps={{ danger: true }}
onConfirm={() => onRegenerate()}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
</Popconfirm>
)
}
return (
<Tooltip content={t('common.regenerate')} delay={800}>
<ActionButton className="message-action-button" onClick={onRegenerate} $softHoverBg={softHoverBg}>
<RefreshIcon size={15} />
</ActionButton>
</Tooltip>
)
},
'assistant-mention-model': ({ isAssistantMessage, onMentionModel, softHoverBg, t }) => {
if (!isAssistantMessage) {
return null
}
return (
<Tooltip content={t('message.mention.title')} delay={800}>
<ActionButton className="message-action-button" onClick={onMentionModel} $softHoverBg={softHoverBg}>
<AtSign size={15} />
</ActionButton>
</Tooltip>
)
},
translate: ({
isUserMessage,
translateLanguages,
handleTranslate,
hasTranslationBlocks,
message,
blockEntities,
removeMessageBlock,
softHoverBg,
t
}) => {
if (isUserMessage) {
return null
}
const items: MenuProps['items'] = [
...translateLanguages.map((item) => ({
label: item.emoji + ' ' + item.label(),
key: item.langCode,
onClick: () => handleTranslate(item)
})),
...(hasTranslationBlocks
? [
{ type: 'divider' as const },
{
label: '📋 ' + t('common.copy'),
key: 'translate-copy',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
if (translationBlocks.length > 0) {
const translationContent = translationBlocks
.map((block) => block?.content || '')
.join('\n\n')
.trim()
if (translationContent) {
navigator.clipboard.writeText(translationContent)
window.toast.success(t('translate.copied'))
} else {
window.toast.warning(t('translate.empty'))
}
}
}
},
{
label: '✖ ' + t('translate.close'),
key: 'translate-close',
onClick: () => {
const translationBlocks = message.blocks
.map((blockId) => blockEntities[blockId])
.filter((block) => block?.type === 'translation')
.map((block) => block?.id)
if (translationBlocks.length > 0) {
translationBlocks.forEach((blockId) => {
if (blockId) {
removeMessageBlock(message.id, blockId)
}
})
window.toast.success(t('translate.closed'))
}
}
}
]
: [])
]
return (
<Dropdown
menu={{
style: {
maxHeight: 250,
overflowY: 'auto',
backgroundClip: 'border-box'
},
items,
onClick: (e) => e.domEvent.stopPropagation()
}}
trigger={['click']}
placement="top"
arrow>
<Tooltip content={t('chat.translate')} delay={1200}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
<Languages size={15} />
</ActionButton>
</Tooltip>
</Dropdown>
)
},
useful: ({ isAssistantMessage, isGrouped, onUseful, softHoverBg, message, t }) => {
if (!isAssistantMessage || !isGrouped) {
return null
}
return (
<Tooltip content={t('chat.message.useful.label')} delay={800}>
<ActionButton className="message-action-button" onClick={onUseful} $softHoverBg={softHoverBg}>
{message.useful ? (
<ThumbsUp size={17.5} fill="var(--color-primary)" strokeWidth={0} />
) : (
<ThumbsUp size={15} />
)}
</ActionButton>
</Tooltip>
)
},
notes: ({ isAssistantMessage, softHoverBg, message, notesPath, t }) => {
if (!isAssistantMessage) {
return null
}
return (
<Tooltip content={t('notes.save')} delay={800}>
<ActionButton
className="message-action-button"
onClick={async (e) => {
e.stopPropagation()
const title = await getMessageTitle(message)
const markdown = await messageToMarkdown(message)
exportMessageToNotes(title, markdown, notesPath)
}}
$softHoverBg={softHoverBg}>
<NotebookPen size={15} />
</ActionButton>
</Tooltip>
)
},
delete: ({
confirmDeleteMessage,
deleteMessage,
message,
setShowDeleteTooltip,
showDeleteTooltip,
softHoverBg,
t
}) => {
const deleteTooltip = (
<Tooltip content={t('common.delete')} delay={1000} isOpen={showDeleteTooltip} onOpenChange={setShowDeleteTooltip}>
<DeleteIcon size={15} />
</Tooltip>
)
if (confirmDeleteMessage) {
return (
<Popconfirm
title={t('message.message.delete.content')}
okButtonProps={{ danger: true }}
onConfirm={() => deleteMessage(message.id, message.traceId, message.model?.name)}
onOpenChange={(open) => open && setShowDeleteTooltip(false)}>
<ActionButton
className="message-action-button"
onClick={(e) => e.stopPropagation()}
$softHoverBg={softHoverBg}>
{deleteTooltip}
</ActionButton>
</Popconfirm>
)
}
return (
<ActionButton
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
deleteMessage(message.id, message.traceId, message.model?.name)
}}
$softHoverBg={softHoverBg}>
{deleteTooltip}
</ActionButton>
)
},
trace: ({ enableDeveloperMode, message, handleTraceUserMessage, t }) => {
if (!enableDeveloperMode || !message.traceId) {
return null
}
return (
<Tooltip content={t('trace.label')} delay={800}>
<ActionButton className="message-action-button" onClick={() => handleTraceUserMessage()}>
<TraceIcon size={16} className={'lucide lucide-trash'} />
</ActionButton>
</Tooltip>
)
},
'more-menu': ({ isUserMessage, dropdownItems, softHoverBg }) => {
if (isUserMessage) {
return null
}
return (
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight">
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()} $softHoverBg={softHoverBg}>
<Menu size={19} />
</ActionButton>
</Dropdown>
)
}
}
export default memo(MessageMenubar)
@@ -2,7 +2,6 @@ import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import ContextMenu from '@renderer/components/ContextMenu'
import { LoadingIcon } from '@renderer/components/Icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
@@ -42,6 +41,7 @@ import MessageAnchorLine from './MessageAnchorLine'
import MessageGroup from './MessageGroup'
import NarrowLayout from './NarrowLayout'
import Prompt from './Prompt'
import { MessagesContainer, ScrollContainer } from './shared'
interface MessagesProps {
assistant: Assistant
@@ -394,25 +394,4 @@ const LoaderContainer = styled.div`
pointer-events: none;
`
const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
padding: 10px 10px 20px;
.multi-select-mode & {
padding-bottom: 60px;
}
`
interface ContainerProps {
$right?: boolean
}
const MessagesContainer = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
overflow-x: hidden;
z-index: 1;
position: relative;
`
export default Messages
@@ -0,0 +1,176 @@
import { AccordionItem, Chip, Code } from '@heroui/react'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useMemo } from 'react'
import { ToolTitle } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
import { AgentToolsType } from './types'
interface ParsedBashOutput {
status?: string
exit_code?: number
stdout?: string
stderr?: string
timestamp?: string
tool_use_error?: string
}
export function BashOutputTool({ input, output }: { input: BashOutputToolInput; output?: BashOutputToolOutput }) {
// 解析 XML 输出
const parsedOutput = useMemo(() => {
if (!output) return null
try {
const parser = new DOMParser()
// 检查是否包含 tool_use_error 标签
const hasToolError = output.includes('<tool_use_error>')
// 包装成有效的 XML(如果还没有根元素)
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
// 检查是否有解析错误
const parserError = xmlDoc.querySelector('parsererror')
if (parserError) {
return null
}
const getElementText = (tagName: string): string | undefined => {
const element = xmlDoc.getElementsByTagName(tagName)[0]
return element?.textContent?.trim()
}
const result: ParsedBashOutput = {
status: getElementText('status'),
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
stdout: getElementText('stdout'),
stderr: getElementText('stderr'),
timestamp: getElementText('timestamp'),
tool_use_error: getElementText('tool_use_error')
}
return result
} catch {
return null
}
}, [output])
// 获取状态配置
const statusConfig = useMemo(() => {
if (!parsedOutput) return null
// 如果有 tool_use_error,直接显示错误状态
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: 'Error'
} as const
}
const isCompleted = parsedOutput.status === 'completed'
const isSuccess = parsedOutput.exit_code === 0
return {
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
icon:
isCompleted && isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : isCompleted && !isSuccess ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}, [parsedOutput])
return (
<AccordionItem
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 */}
<div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && (
<Chip size="sm" color={parsedOutput.exit_code === 0 ? 'success' : 'danger'} variant="flat">
Exit Code: {parsedOutput.exit_code}
</Chip>
)}
{parsedOutput.timestamp && (
<Code size="sm" className="py-0 text-xs">
{new Date(parsedOutput.timestamp).toLocaleString()}
</Code>
)}
</div>
{/* Standard Output */}
{parsedOutput.stdout && (
<div>
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{parsedOutput.stdout}
</pre>
</div>
)}
{/* Standard Error */}
{parsedOutput.stderr && (
<div className="border border-danger-200">
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.stderr}
</pre>
</div>
)}
{/* Tool Use Error */}
{parsedOutput.tool_use_error && (
<div className="border border-danger-200">
<div className="mb-2 flex items-center gap-2">
<XCircle className="h-4 w-4 text-danger" />
<span className="font-medium text-danger-600 text-xs">Error:</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.tool_use_error}
</pre>
</div>
)}
</>
) : (
// 原始输出(如果解析失败或非 XML 格式)
output && (
<div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
</div>
)
)}
</AccordionItem>
)
}
@@ -0,0 +1,31 @@
import { AccordionItem, Code } from '@heroui/react'
import { Terminal } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
export function BashTool({ input, output }: { input: BashToolInputType; output?: BashToolOutputType }) {
// 如果有输出,计算输出行数
const outputLines = output ? output.split('\n').length : 0
return (
<AccordionItem
key="tool"
aria-label="Bash Tool"
title={
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash"
params={input.description}
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
/>
}
subtitle={
<Code size="sm" className="line-clamp-1 w-max max-w-full text-ellipsis py-0 text-xs">
{input.command}
</Code>
}>
<div className="whitespace-pre-line">{output}</div>
</AccordionItem>
)
}
@@ -0,0 +1,46 @@
import { AccordionItem } from '@heroui/react'
import { FileEdit } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { EditToolInput, EditToolOutput } from './types'
import { AgentToolsType } from './types'
// 处理多行文本显示
export const renderCodeBlock = (content: string, variant: 'old' | 'new') => {
const lines = content.split('\n')
const textColorClass =
variant === 'old' ? 'text-danger-600 dark:text-danger-400' : 'text-success-600 dark:text-success-400'
return (
// 删除线
<pre className={`whitespace-pre-wrap font-mono text-xs ${textColorClass}`}>
{lines.map((line, idx) => (
<div key={idx} className="flex hover:bg-default-100/50 dark:hover:bg-default-900/50">
<span className="mr-3 min-w-[2rem] select-none text-right opacity-50">
{variant === 'old' && '-'}
{variant === 'new' && '+'}
{idx + 1}
</span>
<span className={`flex-1 ${variant === 'old' && 'line-through'}`}>{line || ' '}</span>
</div>
))}
</pre>
)
}
export function EditTool({ input, output }: { input: EditToolInput; output?: EditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.Edit}
aria-label="Edit Tool"
title={<ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input.file_path} />}>
{/* Diff View */}
{/* Old Content */}
{renderCodeBlock(input.old_string, 'old')}
{/* New Content */}
{renderCodeBlock(input.new_string, 'new')}
{/* Output */}
{output}
</AccordionItem>
)
}
@@ -0,0 +1,24 @@
import { AccordionItem } from '@heroui/react'
import { DoorOpen } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types'
import { AgentToolsType } from './types'
export function ExitPlanModeTool({ input, output }: { input: ExitPlanModeToolInput; output?: ExitPlanModeToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.ExitPlanMode}
aria-label="ExitPlanMode Tool"
title={
<ToolTitle
icon={<DoorOpen className="h-4 w-4" />}
label="ExitPlanMode"
stats={`${input.plan.split('\n\n').length} plans`}
/>
}>
{<ReactMarkdown>{input.plan + '\n\n' + (output ?? '')}</ReactMarkdown>}
</AccordionItem>
)
}
@@ -0,0 +1,95 @@
// 通用工具组件 - 减少重复代码
import { ReactNode } from 'react'
// 生成 AccordionItem 的标题
export function ToolTitle({
icon,
label,
params,
stats,
className = 'text-sm'
}: {
icon?: ReactNode
label: string
params?: string | ReactNode
stats?: string | ReactNode
className?: string
}) {
return (
<div className={`flex items-center gap-1 ${className}`}>
{icon}
{label && <span className="font-medium text-sm">{label}</span>}
{params && <span className="flex-shrink-0 text-muted-foreground text-xs">{params}</span>}
{stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>}
</div>
)
}
// 纯字符串输入工具 (Task, Bash, Search)
export function StringInputTool({
input,
label,
className = ''
}: {
input: string
label: string
className?: string
}) {
return (
<div className={className}>
<div>{label}:</div>
<div>{input}</div>
</div>
)
}
// 单字段输入工具 (pattern, query, file_path 等)
export function SimpleFieldInputTool({
input,
label,
fieldName,
className = ''
}: {
input: Record<string, any>
label: string
fieldName: string
className?: string
}) {
return (
<div className={className}>
<div>{label}:</div>
<div>
<div>{input[fieldName]}</div>
{/* 显示其他字段(如 Grep 的 output_mode */}
{Object.entries(input)
.filter(([key]) => key !== fieldName)
.map(([key, value]) => (
<span key={key}>
{key}: {String(value)}
</span>
))}
</div>
</div>
)
}
// 字符串输出工具 (Read, Bash, Search, Glob, WebSearch, Grep 等)
export function StringOutputTool({
output,
label,
className = '',
textColor = ''
}: {
output: string
label: string
className?: string
textColor?: string
}) {
return (
<div className={className}>
<div className={textColor}>{label}:</div>
<div>{output}</div>
</div>
)
}
@@ -0,0 +1,26 @@
import { AccordionItem } from '@heroui/react'
import { FolderSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types'
export function GlobTool({ input, output }: { input: GlobToolInputType; output?: GlobToolOutputType }) {
// 如果有输出,计算文件数量
const fileCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Glob Tool"
title={
<ToolTitle
icon={<FolderSearch className="h-4 w-4" />}
label="Glob"
params={input.pattern}
stats={output ? `${fileCount} found` : undefined}
/>
}>
<div>{output}</div>
</AccordionItem>
)
}
@@ -0,0 +1,31 @@
import { AccordionItem } from '@heroui/react'
import { FileSearch } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { GrepToolInput, GrepToolOutput } from './types'
export function GrepTool({ input, output }: { input: GrepToolInput; output?: GrepToolOutput }) {
// 如果有输出,计算结果行数
const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Grep Tool"
title={
<ToolTitle
icon={<FileSearch className="h-4 w-4" />}
label="Grep"
params={
<>
{input.pattern}
{input.output_mode && <span className="ml-1">({input.output_mode})</span>}
</>
}
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
/>
}>
<div>{output}</div>
</AccordionItem>
)
}
@@ -0,0 +1,23 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import { renderCodeBlock } from './EditTool'
import { ToolTitle } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function MultiEditTool({ input }: { input: MultiEditToolInput; output?: MultiEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.MultiEdit}
aria-label="MultiEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input.file_path} />}>
{input.edits.map((edit, index) => (
<div key={index}>
{renderCodeBlock(edit.old_string, 'old')}
{renderCodeBlock(edit.new_string, 'new')}
</div>
))}
</AccordionItem>
)
}
@@ -0,0 +1,19 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types'
export function NotebookEditTool({ input, output }: { input: NotebookEditToolInput; output?: NotebookEditToolOutput }) {
return (
<AccordionItem
key={AgentToolsType.NotebookEdit}
aria-label="NotebookEdit Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />}
subtitle={input.notebook_path}>
<ReactMarkdown>{output}</ReactMarkdown>
</AccordionItem>
)
}
@@ -0,0 +1,60 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
export function ReadTool({ input, output }: { input: ReadToolInputType; output?: ReadToolOutputType }) {
// 将 output 统一转换为字符串
const outputString = useMemo(() => {
if (!output) return null
// 如果是 TextOutput[] 类型,提取所有 text 内容
if (Array.isArray(output)) {
return output
.filter((item): item is TextOutput => item.type === 'text')
.map((item) => item.text)
.join('')
}
// 如果是字符串,直接返回
return output
}, [output])
// 如果有输出,计算统计信息
const stats = useMemo(() => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
}
}, [outputString])
return (
<AccordionItem
key={AgentToolsType.Read}
aria-label="Read Tool"
title={
<ToolTitle
icon={<FileText className="h-4 w-4" />}
label="Read File"
params={input.file_path.split('/').pop()}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
/>
}>
{outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null}
</AccordionItem>
)
}
@@ -0,0 +1,33 @@
import { AccordionItem } from '@heroui/react'
import { Search } from 'lucide-react'
import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools'
import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types'
export function SearchTool({ input, output }: { input: SearchToolInputType; output?: SearchToolOutputType }) {
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Search Tool"
title={
<ToolTitle
icon={<Search className="h-4 w-4" />}
label="Search"
params={`"${input}"`}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
}>
<div>
<StringInputTool input={input} label="Search Query" />
{output && (
<div>
<StringOutputTool output={output} label="Search Results" textColor="text-yellow-600 dark:text-yellow-400" />
</div>
)}
</div>
</AccordionItem>
)
}
@@ -0,0 +1,21 @@
import { AccordionItem } from '@heroui/react'
import { Bot } from 'lucide-react'
import Markdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types'
export function TaskTool({ input, output }: { input: TaskToolInputType; output?: TaskToolOutputType }) {
return (
<AccordionItem
key="tool"
aria-label="Task Tool"
title={<ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input.description} />}>
{output?.map((item) => (
<div key={item.type}>
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
</div>
))}
</AccordionItem>
)
}
@@ -0,0 +1,78 @@
import { AccordionItem, Card, CardBody, Chip } from '@heroui/react'
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type {
TodoItem,
TodoWriteToolInput as TodoWriteToolInputType,
TodoWriteToolOutput as TodoWriteToolOutputType
} from './types'
import { AgentToolsType } from './types'
const getStatusConfig = (status: TodoItem['status']) => {
switch (status) {
case 'completed':
return {
color: 'success' as const,
icon: <CheckCircle className="h-3 w-3" />
}
case 'in_progress':
return {
color: 'primary' as const,
icon: <Clock className="h-3 w-3" />
}
case 'pending':
return {
color: 'default' as const,
icon: <Circle className="h-3 w-3" />
}
default:
return {
color: 'default' as const,
icon: <Circle className="h-3 w-3" />
}
}
}
export function TodoWriteTool({ input, output }: { input: TodoWriteToolInputType; output?: TodoWriteToolOutputType }) {
const doneCount = input.todos.filter((todo) => todo.status === 'completed').length
return (
<AccordionItem
key={AgentToolsType.TodoWrite}
aria-label="Todo Write Tool"
title={
<ToolTitle
icon={<ListTodo className="h-4 w-4" />}
label="Todo Write"
params={`${doneCount} Done`}
stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`}
/>
}>
<div className="space-y-3">
{input.todos.map((todo, index) => {
const statusConfig = getStatusConfig(todo.status)
return (
<Card key={index} className="shadow-sm">
<CardBody className="p-2">
<div className="flex items-start gap-3">
<Chip color={statusConfig.color} variant="flat" size="sm" className="flex-shrink-0">
{statusConfig.icon}
</Chip>
<div className="min-w-0 flex-1">
<div className={`text-sm ${todo.status === 'completed' ? 'text-default-500 line-through' : ''}`}>
{todo.status === 'completed' ? <s>{todo.content}</s> : todo.content}
</div>
{todo.status === 'in_progress' && (
<div className="mt-1 text-default-400 text-xs">{todo.activeForm}</div>
)}
</div>
</div>
</CardBody>
</Card>
)
})}
</div>
{output}
</AccordionItem>
)
}
@@ -0,0 +1,88 @@
import { AccordionItem } from '@heroui/react'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { Wrench } from 'lucide-react'
import { useEffect, useState } from 'react'
import { ToolTitle } from './GenericTools'
interface UnknownToolProps {
toolName: string
input?: unknown
output?: unknown
}
export function UnknownToolRenderer({ toolName = '', input, output }: UnknownToolProps) {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
useEffect(() => {
if (input !== undefined) {
const inputStr = JSON.stringify(input, null, 2)
highlightCode(inputStr, 'json').then(setInputHtml)
}
}, [input, highlightCode])
useEffect(() => {
if (output !== undefined) {
const outputStr = JSON.stringify(output, null, 2)
highlightCode(outputStr, 'json').then(setOutputHtml)
}
}, [output, highlightCode])
const getToolDisplayName = (name: string) => {
if (name.startsWith('mcp__')) {
const parts = name.substring(5).split('__')
if (parts.length >= 2) {
return `${parts[0]}:${parts.slice(1).join(':')}`
}
}
return name
}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
return (
<AccordionItem
key="unknown-tool"
aria-label={toolName}
title={
<ToolTitle
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription()}
/>
}>
<div className="space-y-3">
{input !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
<div
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
dangerouslySetInnerHTML={{ __html: inputHtml }}
/>
</div>
)}
{output !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
<div
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: outputHtml }}
/>
</div>
)}
{input === undefined && output === undefined && (
<div className="text-foreground-500 text-xs">No data available for this tool</div>
)}
</div>
</AccordionItem>
)
}
@@ -0,0 +1,17 @@
import { AccordionItem } from '@heroui/react'
import { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WebFetchToolInput, WebFetchToolOutput } from './types'
export function WebFetchTool({ input, output }: { input: WebFetchToolInput; output?: WebFetchToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Web Fetch Tool"
title={<ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input.url} />}
subtitle={input.prompt}>
{output}
</AccordionItem>
)
}
@@ -0,0 +1,26 @@
import { AccordionItem } from '@heroui/react'
import { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WebSearchToolInput, WebSearchToolOutput } from './types'
export function WebSearchTool({ input, output }: { input: WebSearchToolInput; output?: WebSearchToolOutput }) {
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
return (
<AccordionItem
key="tool"
aria-label="Web Search Tool"
title={
<ToolTitle
icon={<Globe className="h-4 w-4" />}
label="Web Search"
params={input.query}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
/>
}>
{output}
</AccordionItem>
)
}
@@ -0,0 +1,16 @@
import { AccordionItem } from '@heroui/react'
import { FileText } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WriteToolInput, WriteToolOutput } from './types'
export function WriteTool({ input }: { input: WriteToolInput; output?: WriteToolOutput }) {
return (
<AccordionItem
key="tool"
aria-label="Write Tool"
title={<ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input.file_path} />}>
<div>{input.content}</div>
</AccordionItem>
)
}
@@ -0,0 +1,88 @@
import { Accordion } from '@heroui/react'
import { loggerService } from '@logger'
import { NormalToolResponse } from '@renderer/types'
// 导出所有类型
export * from './types'
// 导入所有渲染器
import { BashOutputTool } from './BashOutputTool'
import { BashTool } from './BashTool'
import { EditTool } from './EditTool'
import { ExitPlanModeTool } from './ExitPlanModeTool'
import { GlobTool } from './GlobTool'
import { GrepTool } from './GrepTool'
import { MultiEditTool } from './MultiEditTool'
import { NotebookEditTool } from './NotebookEditTool'
import { ReadTool } from './ReadTool'
import { SearchTool } from './SearchTool'
import { TaskTool } from './TaskTool'
import { TodoWriteTool } from './TodoWriteTool'
import { AgentToolsType, ToolInput, ToolOutput } from './types'
import { UnknownToolRenderer } from './UnknownToolRenderer'
import { WebFetchTool } from './WebFetchTool'
import { WebSearchTool } from './WebSearchTool'
import { WriteTool } from './WriteTool'
const logger = loggerService.withContext('MessageAgentTools')
// 创建工具渲染器映射,这样就实现了完全的类型安全
export const toolRenderers = {
[AgentToolsType.Read]: ReadTool,
[AgentToolsType.Task]: TaskTool,
[AgentToolsType.Bash]: BashTool,
[AgentToolsType.Search]: SearchTool,
[AgentToolsType.Glob]: GlobTool,
[AgentToolsType.TodoWrite]: TodoWriteTool,
[AgentToolsType.WebSearch]: WebSearchTool,
[AgentToolsType.Grep]: GrepTool,
[AgentToolsType.Write]: WriteTool,
[AgentToolsType.WebFetch]: WebFetchTool,
[AgentToolsType.Edit]: EditTool,
[AgentToolsType.MultiEdit]: MultiEditTool,
[AgentToolsType.BashOutput]: BashOutputTool,
[AgentToolsType.NotebookEdit]: NotebookEditTool,
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool
} as const
// 类型守卫函数
export function isValidAgentToolsType(toolName: unknown): toolName is AgentToolsType {
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染函数
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
const Renderer = toolRenderers[toolName]
return (
<div className="w-max max-w-full rounded-md bg-foreground-100 py-1 transition-all duration-300 ease-in-out dark:bg-foreground-100">
<Accordion
className="w-max max-w-full"
itemClasses={{
trigger:
'p-0 [&>div:first-child]:!flex-none [&>div:first-child]:flex [&>div:first-child]:flex-col [&>div:first-child]:text-start [&>div:first-child]:max-w-full',
indicator: 'flex-shrink-0',
subtitle: 'text-xs',
content:
'rounded-md bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll',
base: 'space-y-1'
}}
defaultExpandedKeys={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}>
{Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })}
</Accordion>
</div>
)
}
// 统一的组件渲染入口
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
const { arguments: args, response, tool } = toolResponse
logger.info('Rendering agent tool response', {
tool: tool,
arguments: args,
response
})
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
}
@@ -0,0 +1,378 @@
export enum AgentToolsType {
Read = 'Read',
Task = 'Task',
Bash = 'Bash',
Search = 'Search',
Glob = 'Glob',
TodoWrite = 'TodoWrite',
WebSearch = 'WebSearch',
Grep = 'Grep',
Write = 'Write',
WebFetch = 'WebFetch',
Edit = 'Edit',
MultiEdit = 'MultiEdit',
BashOutput = 'BashOutput',
NotebookEdit = 'NotebookEdit',
ExitPlanMode = 'ExitPlanMode'
}
export type TextOutput = {
type: 'text'
text: string
}
// Read 工具的类型定义
export interface ReadToolInput {
/**
* The absolute path to the file to read
*/
file_path: string
/**
* The line number to start reading from
*/
offset?: number
/**
* The number of lines to read
*/
limit?: number
}
export type ReadToolOutput = string | TextOutput[]
// Task 工具的类型定义
export type TaskToolInput = {
/**
* A short (3-5 word) description of the task
*/
description: string
/**
* The task for the agent to perform
*/
prompt: string
/**
* The type of specialized agent to use for this task
*/
subagent_type: string
}
export type TaskToolOutput = TextOutput[]
// Bash 工具的类型定义
export type BashToolInput = {
/**
* The command to execute
*/
command: string
/**
* Optional timeout in milliseconds (max 600000)
*/
timeout?: number
/**
* Clear, concise description of what this command does in 5-10 words
*/
description?: string
/**
* Set to true to run this command in the background
*/
run_in_background?: boolean
}
export type BashToolOutput = string
// Search 工具的类型定义
export type SearchToolInput = string
export type SearchToolOutput = string
// Glob 工具的类型定义
export interface GlobToolInput {
/**
* The glob pattern to match files against
*/
pattern: string
/**
* The directory to search in (defaults to cwd)
*/
path?: string
}
export type GlobToolOutput = string
// TodoWrite 工具的类型定义
export interface TodoItem {
/**
* The task description
*/
content: string
/**
* The task status
*/
status: 'pending' | 'in_progress' | 'completed'
/**
* Active form of the task description
*/
activeForm: string
}
export type TodoWriteToolInput = {
todos: TodoItem[]
}
export type TodoWriteToolOutput = string
// WebSearch 工具的类型定义
export interface WebSearchToolInput {
/**
* The search query to use
*/
query: string
/**
* Only include results from these domains
*/
allowed_domains?: string[]
/**
* Never include results from these domains
*/
blocked_domains?: string[]
}
export type WebSearchToolOutput = string
// WebFetch 工具的类型定义
export type WebFetchToolInput = {
/**
* The URL to fetch content from
*/
url: string
/**
* The prompt to run on the fetched content
*/
prompt: string
}
export type WebFetchToolOutput = string
// Grep 工具的类型定义
export interface GrepToolInput {
/**
* The regular expression pattern to search for
*/
pattern: string
/**
* File or directory to search in (defaults to cwd)
*/
path?: string
/**
* Glob pattern to filter files (e.g. "*.js")
*/
glob?: string
/**
* File type to search (e.g. "js", "py", "rust")
*/
type?: string
/**
* Output mode: "content", "files_with_matches", or "count"
*/
output_mode?: 'content' | 'files_with_matches' | 'count'
/**
* Case insensitive search
*/
'-i'?: boolean
/**
* Show line numbers (for content mode)
*/
'-n'?: boolean
/**
* Lines to show before each match
*/
'-B'?: number
/**
* Lines to show after each match
*/
'-A'?: number
/**
* Lines to show before and after each match
*/
'-C'?: number
/**
* Limit output to first N lines/entries
*/
head_limit?: number
/**
* Enable multiline mode
*/
multiline?: boolean
}
export type GrepToolOutput = string
// Write 工具的类型定义
export type WriteToolInput = {
/**
* The absolute path to the file to write
*/
file_path: string
/**
* The content to write to the file
*/
content: string
}
export type WriteToolOutput = string
// Edit 工具的类型定义
export type EditToolInput = {
/**
* The absolute path to the file to modify
*/
file_path: string
/**
* The text to replace
*/
old_string: string
/**
* The text to replace it with (must be different from old_string)
*/
new_string: string
/**
* Replace all occurrences of old_string (default false)
*/
replace_all?: boolean
}
export type EditToolOutput = string
// MultiEdit 工具的类型定义
export type MultiEditToolInput = {
/**
* The absolute path to the file to modify
*/
file_path: string
/**
* Array of edit operations to perform sequentially
*/
edits: Array<{
/**
* The text to replace
*/
old_string: string
/**
* The text to replace it with
*/
new_string: string
/**
* Replace all occurrences (default false)
*/
replace_all?: boolean
}>
}
export type MultiEditToolOutput = string
// BashOutput 工具的类型定义
export type BashOutputToolInput = {
/**
* The ID of the background shell to retrieve output from
*/
bash_id: string
/**
* Optional regex to filter output lines
*/
filter?: string
}
export type BashOutputToolOutput = string
// NotebookEdit 工具的类型定义
export type NotebookEditToolInput = {
/**
* The absolute path to the Jupyter notebook file
*/
notebook_path: string
/**
* The ID of the cell to edit
*/
cell_id?: string
/**
* The new source for the cell
*/
new_source: string
/**
* The type of the cell (code or markdown)
*/
cell_type?: 'code' | 'markdown'
/**
* The type of edit (replace, insert, delete)
*/
edit_mode?: 'replace' | 'insert' | 'delete'
}
export type NotebookEditToolOutput = string
// ExitPlanModeToolInput
export type ExitPlanModeToolInput = {
/**
* The plan to run by the user for approval
*/
plan: string
}
export type ExitPlanModeToolOutput = string
// ListMcpResourcesToolInput
export type ListMcpResourcesToolInput = {
/**
* Optional server name to filter resources by
*/
server?: string
}
// ReadMcpResourceToolInput
export type ReadMcpResourceToolInput = {
/**
* The MCP server name
*/
server: string
/**
* The resource URI to read
*/
uri: string
}
export type KillBashToolInput = {
/**
* The ID of the background shell to kill
*/
shell_id: string
}
// 联合类型
export type ToolInput =
| TaskToolInput
| BashToolInput
| BashOutputToolInput
| EditToolInput
| MultiEditToolInput
| ReadToolInput
| WriteToolInput
| GlobToolInput
| GrepToolInput
| KillBashToolInput
| NotebookEditToolInput
| WebFetchToolInput
| WebSearchToolInput
| TodoWriteToolInput
| ExitPlanModeToolInput
| ListMcpResourcesToolInput
| ReadMcpResourceToolInput
export type ToolOutput =
| ReadToolOutput
| TaskToolOutput
| BashToolOutput
| SearchToolOutput
| GlobToolOutput
| TodoWriteToolOutput
| WebSearchToolOutput
| GrepToolOutput
| WebFetchToolOutput
| WriteToolOutput
| EditToolOutput
| MultiEditToolOutput
| BashOutputToolOutput
| NotebookEditToolOutput
| ExitPlanModeToolOutput
// 工具渲染器接口
export interface ToolRenderer {
render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement
}
@@ -52,7 +52,7 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
const { id, tool, status, response } = toolResponse!
const { id, tool, status, response } = toolResponse as MCPToolResponse
const isPending = status === 'pending'
const isDone = status === 'done'
const isError = status === 'error'
@@ -1,7 +1,7 @@
import type { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { Collapse } from 'antd'
import { MessageAgentTools } from './MessageAgentTools'
import { MessageKnowledgeSearchToolTitle } from './MessageKnowledgeSearch'
import { MessageMemorySearchToolTitle } from './MessageMemorySearch'
import { MessageWebSearchToolTitle } from './MessageWebSearch'
@@ -10,36 +10,51 @@ interface Props {
block: ToolMessageBlock
}
const prefix = 'builtin_'
const agentPrefix = 'mcp__'
const agentTools = [
'Read',
'Task',
'Bash',
'Search',
'Glob',
'TodoWrite',
'WebSearch',
'Grep',
'Write',
'WebFetch',
'Edit',
'MultiEdit',
'BashOutput',
'NotebookEdit',
'ExitPlanMode'
]
const isAgentTool = (toolName: string) => {
if (agentTools.includes(toolName) || toolName.startsWith(agentPrefix)) {
return true
}
return false
}
const ChooseTool = (toolResponse: NormalToolResponse): { label: React.ReactNode; body: React.ReactNode } | null => {
const ChooseTool = (toolResponse: NormalToolResponse): React.ReactNode | null => {
let toolName = toolResponse.tool.name
const toolType = toolResponse.tool.type
if (toolName.startsWith(prefix)) {
toolName = toolName.slice(prefix.length)
switch (toolName) {
case 'web_search':
case 'web_search_preview':
return toolType === 'provider' ? null : <MessageWebSearchToolTitle toolResponse={toolResponse} />
case 'knowledge_search':
return <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />
case 'memory_search':
return <MessageMemorySearchToolTitle toolResponse={toolResponse} />
default:
return null
}
} else if (isAgentTool(toolName)) {
return <MessageAgentTools toolResponse={toolResponse} />
}
switch (toolName) {
case 'web_search':
case 'web_search_preview':
return toolType === 'provider'
? null
: {
label: <MessageWebSearchToolTitle toolResponse={toolResponse} />,
body: null
}
case 'knowledge_search':
return {
label: <MessageKnowledgeSearchToolTitle toolResponse={toolResponse} />,
body: null
}
case 'memory_search':
return {
label: <MessageMemorySearchToolTitle toolResponse={toolResponse} />,
body: null
}
default:
return null
}
return null
}
export default function MessageTool({ block }: Props) {
@@ -48,32 +63,13 @@ export default function MessageTool({ block }: Props) {
if (!toolResponse) return null
const toolRenderer = ChooseTool(toolResponse)
const toolRenderer = ChooseTool(toolResponse as NormalToolResponse)
if (!toolRenderer) return null
return toolRenderer.body ? (
<Collapse
items={[
{
key: '1',
label: toolRenderer.label,
children: toolRenderer.body,
showArrow: false,
styles: {
header: {
paddingLeft: '0'
}
}
}
]}
size="small"
ghost
/>
) : (
toolRenderer.label
)
return toolRenderer
}
// const PrepareToolWrapper = styled.span`
// display: flex;
// align-items: center;
@@ -0,0 +1,23 @@
import Scrollbar from '@renderer/components/Scrollbar'
import styled from 'styled-components'
export const ScrollContainer = styled.div`
display: flex;
flex-direction: column-reverse;
padding: 10px 10px 20px;
.multi-select-mode & {
padding-bottom: 60px;
}
`
interface ContainerProps {
$right?: boolean
}
export const MessagesContainer = styled(Scrollbar)<ContainerProps>`
display: flex;
flex-direction: column-reverse;
overflow-x: hidden;
z-index: 1;
position: relative;
`
@@ -1,21 +1,11 @@
import { DownOutlined, RightOutlined } from '@ant-design/icons'
import { Tooltip } from '@cherrystudio/ui'
import { DraggableList } from '@renderer/components/DraggableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { useAgents } from '@renderer/hooks/useAgents'
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useAssistantsTabSortType } from '@renderer/hooks/useStore'
import { useTags } from '@renderer/hooks/useTags'
import type { Assistant } from '@renderer/types'
import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes'
import { Typography } from 'antd'
import { Plus } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRef } from 'react'
import styled from 'styled-components'
import AssistantItem from './components/AssistantItem'
import { AgentSection } from './components/AgentSection'
import Assistants from './components/Assistants'
interface AssistantsTabProps {
activeAssistant: Assistant
@@ -23,151 +13,17 @@ interface AssistantsTabProps {
onCreateAssistant: () => void
onCreateDefaultAssistant: () => void
}
const Assistants: FC<AssistantsTabProps> = ({
activeAssistant,
setActiveAssistant,
onCreateAssistant,
onCreateDefaultAssistant
}) => {
const { assistants, removeAssistant, copyAssistant, updateAssistants } = useAssistants()
const [dragging, setDragging] = useState(false)
const { addAgent } = useAgents()
const { t } = useTranslation()
const { getGroupedAssistants, collapsedTags, toggleTagCollapse } = useTags()
const { assistantsTabSortType = 'list', setAssistantsTabSortType } = useAssistantsTabSortType()
const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const containerRef = useRef<HTMLDivElement>(null)
const onDelete = useCallback(
(assistant: Assistant) => {
const remaining = assistants.filter((a) => a.id !== assistant.id)
if (assistant.id === activeAssistant?.id) {
const newActive = remaining[remaining.length - 1]
newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant()
}
removeAssistant(assistant.id)
},
[activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant]
)
const handleSortByChange = useCallback(
(sortType: AssistantTabSortType) => {
setAssistantsTabSortType(sortType)
},
[setAssistantsTabSortType]
)
const handleGroupReorder = useCallback(
(tag: string, newGroupList: Assistant[]) => {
let insertIndex = 0
const newGlobal = assistants.map((a) => {
const tags = a.tags?.length ? a.tags : [t('assistants.tags.untagged')]
if (tags.includes(tag)) {
const replaced = newGroupList[insertIndex]
insertIndex += 1
return replaced
}
return a
})
updateAssistants(newGlobal)
},
[assistants, t, updateAssistants]
)
const renderAddAssistantButton = useMemo(() => {
return (
<AssistantAddItem onClick={onCreateAssistant}>
<AddItemWrapper>
<Plus size={16} style={{ marginRight: 4, flexShrink: 0 }} />
<Typography.Text style={{ color: 'inherit' }} ellipsis={{ tooltip: t('chat.add.assistant.title') }}>
{t('chat.add.assistant.title')}
</Typography.Text>
</AddItemWrapper>
</AssistantAddItem>
)
}, [onCreateAssistant, t])
if (assistantsTabSortType === 'tags') {
return (
<Container className="assistants-tab" ref={containerRef}>
<div style={{ marginBottom: '8px' }}>
{getGroupedAssistants.map((group) => (
<TagsContainer key={group.tag}>
{group.tag !== t('assistants.tags.untagged') && (
<GroupTitle onClick={() => toggleTagCollapse(group.tag)}>
<Tooltip content={group.tag}>
<GroupTitleName>
{collapsedTags[group.tag] ? (
<RightOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
) : (
<DownOutlined style={{ fontSize: '10px', marginRight: '5px' }} />
)}
{group.tag}
</GroupTitleName>
</Tooltip>
<GroupTitleDivider />
</GroupTitle>
)}
{!collapsedTags[group.tag] && (
<div>
<DraggableList
list={group.assistants}
onUpdate={(newList) => handleGroupReorder(group.tag, newList)}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (
<AssistantItem
key={assistant.id}
assistant={assistant}
isActive={assistant.id === activeAssistant.id}
sortBy={assistantsTabSortType}
onSwitch={setActiveAssistant}
onDelete={onDelete}
addAgent={addAgent}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
/>
)}
</DraggableList>
</div>
)}
</TagsContainer>
))}
</div>
{renderAddAssistantButton}
</Container>
)
}
return (
<Container className="assistants-tab" ref={containerRef}>
<DraggableList
list={assistants}
onUpdate={updateAssistants}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(assistant) => (
<AssistantItem
key={assistant.id}
assistant={assistant}
isActive={assistant.id === activeAssistant.id}
sortBy={assistantsTabSortType}
onSwitch={setActiveAssistant}
onDelete={onDelete}
addAgent={addAgent}
copyAssistant={copyAssistant}
onCreateDefaultAssistant={onCreateDefaultAssistant}
handleSortByChange={handleSortByChange}
/>
)}
</DraggableList>
{!dragging && renderAddAssistantButton}
<div style={{ minHeight: 10 }}></div>
<AgentSection />
<Assistants {...props} />
</Container>
)
}
// 样式组件
const Container = styled(Scrollbar)`
display: flex;
flex-direction: column;
@@ -175,67 +31,4 @@ const Container = styled(Scrollbar)`
margin-top: 3px;
`
const TagsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`
const AssistantAddItem = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 7px 12px;
position: relative;
padding-right: 35px;
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);
}
`
const GroupTitle = styled.div`
color: var(--color-text-2);
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 24px;
margin: 5px 0;
`
const GroupTitleName = styled.div`
max-width: 50%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
padding: 0 4px;
color: var(--color-text);
font-size: 13px;
line-height: 24px;
margin-right: 5px;
display: flex;
`
const GroupTitleDivider = styled.div`
flex: 1;
border-top: 1px solid var(--color-border);
`
const AddItemWrapper = styled.div`
color: var(--color-text-2);
font-size: 13px;
display: flex;
align-items: center;
white-space: nowrap;
overflow: hidden;
`
export default Assistants
export default AssistantsTab
@@ -0,0 +1,52 @@
import { Alert, cn } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { AnimatePresence, motion } from 'framer-motion'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
import Sessions from './components/Sessions'
interface SessionsTabProps {}
const SessionsTab: FC<SessionsTabProps> = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
const { t } = useTranslation()
const { apiServer } = useSettings()
const { topicPosition, navbarPosition } = useSettings()
if (!apiServer.enabled) {
return (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
}
if (!activeAgentId) {
return (
<div>
<Alert color="warning" title={'Select an agent'} />
</div>
)
}
return (
<AnimatePresence mode="wait">
<motion.div
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'var(--assistants-width)', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.5, ease: 'easeInOut' }}
className={cn(
'overflow-hidden',
topicPosition === 'right' && navbarPosition === 'top' ? 'rounded-l-2xl border-t border-b border-l' : undefined
)}>
<Sessions agentId={activeAgentId} />
</motion.div>
</AnimatePresence>
)
}
export default memo(SessionsTab)
+114 -114
View File
@@ -19,7 +19,7 @@ import { modalConfirm } from '@renderer/utils'
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
import type { MultiModelMessageStyle, SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { Col, InputNumber, Row, Slider } from 'antd'
import { Col, InputNumber, Row, Slider, Switch } from 'antd'
import { Settings2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -223,131 +223,131 @@ const SettingsTab: FC<Props> = (props) => {
return (
<Container className="settings-tab">
<CollapsibleSettingGroup
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<RowFlex className="items-center gap-0.5">
<Button
variant="light"
size="sm"
isIconOnly
onPress={() => AssistantSettingsPopup.show({ assistant, tab: 'model' })}>
<Settings2 size={16} />
</Button>
</RowFlex>
}>
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
{/* <SettingRowTitleSmall>
{t('chat.settings.temperature.label')}
<HelpTooltip title={t('chat.settings.temperature.tip')} />
</SettingRowTitleSmall> */}
<DescriptionSwitch
size="sm"
className="ml-auto"
isSelected={enableTemperature}
onValueChange={(enabled) => {
setEnableTemperature(enabled)
onUpdateAssistantSettings({ enableTemperature: enabled })
}}>
{props.assistant.id !== 'fake' && (
<CollapsibleSettingGroup
title={t('assistants.settings.title')}
defaultExpanded={true}
extra={
<RowFlex className="items-center gap-0.5">
<Button
variant="light"
size="sm"
isIconOnly
onPress={() => AssistantSettingsPopup.show({ assistant, tab: 'model' })}>
<Settings2 size={16} />
</Button>
</RowFlex>
}>
<SettingGroup style={{ marginTop: 5 }}>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.temperature.label')}
<HelpTooltip content={t('chat.settings.temperature.tip')} />
<HelpTooltip title={t('chat.settings.temperature.tip')} />
</SettingRowTitleSmall>
</DescriptionSwitch>
</Row>
{enableTemperature ? (
<Switch
size="small"
style={{ marginLeft: 'auto' }}
checked={enableTemperature}
onChange={(enabled) => {
setEnableTemperature(enabled)
onUpdateAssistantSettings({ enableTemperature: enabled })
}}
/>
</Row>
{enableTemperature ? (
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
step={0.1}
/>
</Col>
</Row>
) : (
<SettingDivider />
)}
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip title={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
</Row>
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={2}
onChange={setTemperature}
onChangeComplete={onTemperatureChange}
value={typeof temperature === 'number' ? temperature : 0}
step={0.1}
max={maxContextCount}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
/>
</Col>
</Row>
) : (
<SettingDivider />
)}
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.context_count.label')}
<HelpTooltip content={t('chat.settings.context_count.tip')} />
</SettingRowTitleSmall>
</Row>
<Row align="middle" gutter={10}>
<Col span={23}>
<Slider
min={0}
max={maxContextCount}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
/>
</Col>
</Row>
<SettingDivider />
<SettingRow>
<DescriptionSwitch
size="sm"
isSelected={streamOutput}
onValueChange={(checked) => {
setStreamOutput(checked)
onUpdateAssistantSettings({ streamOutput: checked })
}}>
<SettingRow>
<SettingRowTitleSmall>{t('models.stream_output')}</SettingRowTitleSmall>
</DescriptionSwitch>
</SettingRow>
<SettingDivider />
<SettingRow>
<DescriptionSwitch
size="sm"
isSelected={enableMaxTokens}
onValueChange={async (enabled) => {
if (enabled) {
const confirmed = await modalConfirm({
title: t('chat.settings.max_tokens.confirm'),
content: t('chat.settings.max_tokens.confirm_content'),
okButtonProps: {
danger: true
}
})
if (!confirmed) return
}
setEnableMaxTokens(enabled)
onUpdateAssistantSettings({ enableMaxTokens: enabled })
}}>
<SettingRowTitleSmall>
{t('chat.settings.max_tokens.label')}
<HelpTooltip content={t('chat.settings.max_tokens.tip')} />
</SettingRowTitleSmall>
</DescriptionSwitch>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
min={0}
max={10000000}
step={100}
value={typeof maxTokens === 'number' ? maxTokens : 0}
changeOnBlur
onChange={(value) => value && setMaxTokens(value)}
onBlur={() => onMaxTokensChange(maxTokens)}
style={{ width: '100%' }}
/>
</Col>
</Row>
)}
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
<Switch
size="small"
checked={streamOutput}
onChange={(checked) => {
setStreamOutput(checked)
onUpdateAssistantSettings({ streamOutput: checked })
}}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<Row align="middle">
<SettingRowTitleSmall>
{t('chat.settings.max_tokens.label')}
<HelpTooltip title={t('chat.settings.max_tokens.tip')} />
</SettingRowTitleSmall>
</Row>
<Switch
size="small"
checked={enableMaxTokens}
onChange={async (enabled) => {
if (enabled) {
const confirmed = await modalConfirm({
title: t('chat.settings.max_tokens.confirm'),
content: t('chat.settings.max_tokens.confirm_content'),
okButtonProps: {
danger: true
}
})
if (!confirmed) return
}
setEnableMaxTokens(enabled)
onUpdateAssistantSettings({ enableMaxTokens: enabled })
}}
/>
</SettingRow>
{enableMaxTokens && (
<Row align="middle" gutter={10} style={{ marginTop: 10 }}>
<Col span={24}>
<InputNumber
disabled={!enableMaxTokens}
min={0}
max={10000000}
step={100}
value={typeof maxTokens === 'number' ? maxTokens : 0}
changeOnBlur
onChange={(value) => value && setMaxTokens(value)}
onBlur={() => onMaxTokensChange(maxTokens)}
style={{ width: '100%' }}
/>
</Col>
</Row>
)}
<SettingDivider />
</SettingGroup>
</CollapsibleSettingGroup>
)}
{isOpenAI && (
<OpenAISettingsGroup
model={model}
+16 -804
View File
@@ -1,59 +1,10 @@
import { HelpTooltip } from '@cherrystudio/ui'
import { Tooltip } from '@cherrystudio/ui'
import { useCache } from '@data/hooks/useCache'
import { usePreference } from '@data/hooks/usePreference'
import { useMultiplePreferences } from '@data/hooks/usePreference'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons'
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import PromptPopup from '@renderer/components/Popups/PromptPopup'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import { isMac } from '@renderer/config/constant'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
import { modelGenerating } from '@renderer/hooks/useModel'
import { useNotesSettings } from '@renderer/hooks/useNotesSettings'
import { finishTopicRenaming, startTopicRenaming, TopicManager } from '@renderer/hooks/useTopic'
import { fetchMessagesSummary } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { RootState } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { useRuntime } from '@renderer/hooks/useRuntime'
import type { Assistant, Topic } from '@renderer/types'
import { classNames, removeSpecialCharactersForFileName } from '@renderer/utils'
import { copyTopicAsMarkdown, copyTopicAsPlainText } from '@renderer/utils/copy'
import {
exportMarkdownToJoplin,
exportMarkdownToSiyuan,
exportMarkdownToYuque,
exportTopicAsMarkdown,
exportTopicToNotes,
exportTopicToNotion,
topicToMarkdown
} from '@renderer/utils/export'
import type { MenuProps } from 'antd'
import { Dropdown } from 'antd'
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
import dayjs from 'dayjs'
import { findIndex } from 'lodash'
import {
BrushCleaning,
FolderOpen,
MenuIcon,
NotebookPen,
PackagePlus,
PinIcon,
PinOffIcon,
PlusIcon,
Save,
Sparkles,
UploadIcon,
XIcon
} from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import styled from 'styled-components'
import { Topics } from './components/Topics'
import SessionsTab from './SessionsTab'
// const logger = loggerService.withContext('TopicsTab')
interface Props {
@@ -63,755 +14,16 @@ interface Props {
position: 'left' | 'right'
}
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic, position }) => {
const [topicPosition, setTopicPosition] = usePreference('topic.position')
const [showTopicTime] = usePreference('topic.tab.show_time')
const [pinTopicsToTop] = usePreference('topic.tab.pin_to_top')
const [, setGenerating] = useCache('chat.generating')
const { t } = useTranslation()
const { notesPath } = useNotesSettings()
const { assistants } = useAssistants()
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
const [renamingTopics] = useCache('topic.renaming')
const [newlyRenamedTopics] = useCache('topic.newly_renamed')
const topicLoadingQuery = useSelector((state: RootState) => state.messages.loadingByTopic)
const topicFulfilledQuery = useSelector((state: RootState) => state.messages.fulfilledByTopic)
const borderRadius = showTopicTime ? 12 : 'var(--list-item-border-radius)'
const [deletingTopicId, setDeletingTopicId] = useState<string | null>(null)
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
const topicEdit = useInPlaceEdit({
onSave: (name: string) => {
const topic = assistant.topics.find((t) => t.id === editingTopicId)
if (topic && name !== topic.name) {
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
updateTopic(updatedTopic)
window.toast.success(t('common.saved'))
}
setEditingTopicId(null)
},
onCancel: () => {
setEditingTopicId(null)
}
})
const isPending = useCallback((topicId: string) => topicLoadingQuery[topicId], [topicLoadingQuery])
const isFulfilled = useCallback((topicId: string) => topicFulfilledQuery[topicId], [topicFulfilledQuery])
const dispatch = useDispatch()
useEffect(() => {
dispatch(newMessagesActions.setTopicFulfilled({ topicId: activeTopic.id, fulfilled: false }))
}, [activeTopic.id, dispatch, topicFulfilledQuery])
const isRenaming = useCallback(
(topicId: string) => {
return renamingTopics.includes(topicId)
},
[renamingTopics]
)
const isNewlyRenamed = useCallback(
(topicId: string) => {
return newlyRenamedTopics.includes(topicId)
},
[newlyRenamedTopics]
)
const handleDeleteClick = useCallback((topicId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (deleteTimerRef.current) {
clearTimeout(deleteTimerRef.current)
}
setDeletingTopicId(topicId)
deleteTimerRef.current = setTimeout(() => setDeletingTopicId(null), 2000)
}, [])
const onClearMessages = useCallback(
(topic: Topic) => {
// cacheService.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true)
setGenerating(false)
EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES, topic)
},
[setGenerating]
)
const handleConfirmDelete = useCallback(
async (topic: Topic, e: React.MouseEvent) => {
e.stopPropagation()
if (assistant.topics.length === 1) {
return onClearMessages(topic)
}
await modelGenerating()
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
if (topic.id === activeTopic.id) {
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
}
removeTopic(topic)
setDeletingTopicId(null)
},
[activeTopic.id, assistant.topics, onClearMessages, removeTopic, setActiveTopic]
)
const onPinTopic = useCallback(
(topic: Topic) => {
const updatedTopic = { ...topic, pinned: !topic.pinned }
updateTopic(updatedTopic)
},
[updateTopic]
)
const onDeleteTopic = useCallback(
async (topic: Topic) => {
await modelGenerating()
if (topic.id === activeTopic?.id) {
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? index - 1 : index + 1])
}
removeTopic(topic)
},
[assistant.topics, removeTopic, setActiveTopic, activeTopic]
)
const onMoveTopic = useCallback(
async (topic: Topic, toAssistant: Assistant) => {
await modelGenerating()
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
moveTopic(topic, toAssistant)
},
[assistant.topics, moveTopic, setActiveTopic]
)
const onSwitchTopic = useCallback(
async (topic: Topic) => {
// await modelGenerating()
setActiveTopic(topic)
},
[setActiveTopic]
)
const [exportMenuOptions] = useMultiplePreferences({
image: 'data.export.menus.image',
markdown: 'data.export.menus.markdown',
markdown_reason: 'data.export.menus.markdown_reason',
notion: 'data.export.menus.notion',
yuque: 'data.export.menus.yuque',
joplin: 'data.export.menus.joplin',
obsidian: 'data.export.menus.obsidian',
siyuan: 'data.export.menus.siyuan',
docx: 'data.export.menus.docx',
plain_text: 'data.export.menus.plain_text'
})
const [_targetTopic, setTargetTopic] = useState<Topic | null>(null)
const targetTopic = useDeferredValue(_targetTopic)
const getTopicMenuItems = useMemo(() => {
const topic = targetTopic
if (!topic) return []
const menus: MenuProps['items'] = [
{
label: t('chat.topics.auto_rename'),
key: 'auto-rename',
icon: <Sparkles size={14} />,
disabled: isRenaming(topic.id),
async onClick() {
const messages = await TopicManager.getTopicMessages(topic.id)
if (messages.length >= 2) {
startTopicRenaming(topic.id)
try {
const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) {
const updatedTopic = { ...topic, name: summaryText, isNameManuallyEdited: false }
updateTopic(updatedTopic)
} else {
window.toast?.error(t('message.error.fetchTopicName'))
}
} finally {
finishTopicRenaming(topic.id)
}
}
}
},
{
label: t('chat.topics.edit.title'),
key: 'rename',
icon: <EditIcon size={14} />,
disabled: isRenaming(topic.id),
async onClick() {
const name = await PromptPopup.show({
title: t('chat.topics.edit.title'),
message: '',
defaultValue: topic?.name || '',
extraNode: (
<div style={{ color: 'var(--color-text-3)', marginTop: 8 }}>{t('chat.topics.edit.title_tip')}</div>
)
})
if (name && topic?.name !== name) {
const updatedTopic = { ...topic, name, isNameManuallyEdited: true }
updateTopic(updatedTopic)
}
}
},
{
label: t('chat.topics.prompt.label'),
key: 'topic-prompt',
icon: <PackagePlus size={14} />,
extra: <HelpTooltip content={t('chat.topics.prompt.tips')} />,
async onClick() {
const prompt = await PromptPopup.show({
title: t('chat.topics.prompt.edit.title'),
message: '',
defaultValue: topic?.prompt || '',
inputProps: {
rows: 8,
allowClear: true
}
})
prompt !== null &&
(() => {
const updatedTopic = { ...topic, prompt: prompt.trim() }
updateTopic(updatedTopic)
topic.id === activeTopic.id && setActiveTopic(updatedTopic)
})()
}
},
{
label: topic.pinned ? t('chat.topics.unpin') : t('chat.topics.pin'),
key: 'pin',
icon: topic.pinned ? <PinOffIcon size={14} /> : <PinIcon size={14} />,
onClick() {
onPinTopic(topic)
}
},
{
label: t('notes.save'),
key: 'notes',
icon: <NotebookPen size={14} />,
onClick: async () => {
exportTopicToNotes(topic, notesPath)
}
},
{
label: t('chat.topics.clear.title'),
key: 'clear-messages',
icon: <BrushCleaning size={14} />,
async onClick() {
window.modal.confirm({
title: t('chat.input.clear.content'),
centered: true,
onOk: () => onClearMessages(topic)
})
}
},
{
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('chat.topics.copy.title'),
key: 'copy',
icon: <CopyIcon size={14} />,
children: [
{
label: t('chat.topics.copy.image'),
key: 'img',
onClick: () => EventEmitter.emit(EVENT_NAMES.COPY_TOPIC_IMAGE, topic)
},
{
label: t('chat.topics.copy.md'),
key: 'md',
onClick: () => copyTopicAsMarkdown(topic)
},
{
label: t('chat.topics.copy.plain_text'),
key: 'plain_text',
onClick: () => copyTopicAsPlainText(topic)
}
]
},
{
label: t('chat.save.label'),
key: 'save',
icon: <Save size={14} />,
children: [
{
label: t('chat.save.topic.knowledge.title'),
key: 'knowledge',
onClick: async () => {
try {
const result = await SaveToKnowledgePopup.showForTopic(topic)
if (result?.success) {
window.toast.success(t('chat.save.topic.knowledge.success', { count: result.savedCount }))
}
} catch {
window.toast.error(t('chat.save.topic.knowledge.error.save_failed'))
}
}
}
]
},
{
label: t('chat.topics.export.title'),
key: 'export',
icon: <UploadIcon size={14} />,
children: [
exportMenuOptions.image && {
label: t('chat.topics.export.image'),
key: 'image',
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
},
exportMenuOptions.markdown && {
label: t('chat.topics.export.md.label'),
key: 'markdown',
onClick: () => exportTopicAsMarkdown(topic)
},
exportMenuOptions.markdown_reason && {
label: t('chat.topics.export.md.reason'),
key: 'markdown_reason',
onClick: () => exportTopicAsMarkdown(topic, true)
},
exportMenuOptions.docx && {
label: t('chat.topics.export.word'),
key: 'word',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
window.api.export.toWord(markdown, removeSpecialCharactersForFileName(topic.name))
}
},
exportMenuOptions.notion && {
label: t('chat.topics.export.notion'),
key: 'notion',
onClick: async () => {
exportTopicToNotion(topic)
}
},
exportMenuOptions.yuque && {
label: t('chat.topics.export.yuque'),
key: 'yuque',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToYuque(topic.name, markdown)
}
},
exportMenuOptions.obsidian && {
label: t('chat.topics.export.obsidian'),
key: 'obsidian',
onClick: async () => {
await ObsidianExportPopup.show({ title: topic.name, topic, processingMethod: '3' })
}
},
exportMenuOptions.joplin && {
label: t('chat.topics.export.joplin'),
key: 'joplin',
onClick: async () => {
const topicMessages = await TopicManager.getTopicMessages(topic.id)
exportMarkdownToJoplin(topic.name, topicMessages)
}
},
exportMenuOptions.siyuan && {
label: t('chat.topics.export.siyuan'),
key: 'siyuan',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
exportMarkdownToSiyuan(topic.name, markdown)
}
}
].filter(Boolean) as ItemType<MenuItemType>[]
}
]
if (assistants.length > 1 && assistant.topics.length > 1) {
menus.push({
label: t('chat.topics.move_to'),
key: 'move',
icon: <FolderOpen size={14} />,
children: assistants
.filter((a) => a.id !== assistant.id)
.map((a) => ({
label: a.name,
key: a.id,
onClick: () => onMoveTopic(topic, a)
}))
})
}
if (assistant.topics.length > 1 && !topic.pinned) {
menus.push({ type: 'divider' })
menus.push({
label: t('common.delete'),
danger: true,
key: 'delete',
icon: <DeleteIcon size={14} className="lucide-custom" />,
onClick: () => onDeleteTopic(topic)
})
}
return menus
}, [
targetTopic,
t,
isRenaming,
exportMenuOptions.image,
exportMenuOptions.markdown,
exportMenuOptions.markdown_reason,
exportMenuOptions.docx,
exportMenuOptions.notion,
exportMenuOptions.yuque,
exportMenuOptions.obsidian,
exportMenuOptions.joplin,
exportMenuOptions.siyuan,
assistants,
notesPath,
assistant,
updateTopic,
activeTopic.id,
setActiveTopic,
onPinTopic,
onClearMessages,
setTopicPosition,
onMoveTopic,
onDeleteTopic
])
// Sort topics based on pinned status if pinTopicsToTop is enabled
const sortedTopics = useMemo(() => {
if (pinTopicsToTop) {
return [...assistant.topics].sort((a, b) => {
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
})
}
return assistant.topics
}, [assistant.topics, pinTopicsToTop])
const singlealone = topicPosition === 'right' && position === 'right'
return (
<DraggableVirtualList
className="topics-tab"
list={sortedTopics}
onUpdate={updateTopics}
style={{ height: '100%', padding: '13px 0 10px 10px' }}
itemContainerStyle={{ paddingBottom: '8px' }}
header={
<AddTopicButton onClick={() => EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}>
<PlusIcon size={16} />
{t('chat.add.topic.title')}
</AddTopicButton>
}>
{(topic) => {
const isActive = topic.id === activeTopic?.id
const topicName = topic.name.replace('`', '')
const topicPrompt = topic.prompt
const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt
const getTopicNameClassName = () => {
if (isRenaming(topic.id)) return 'shimmer'
if (isNewlyRenamed(topic.id)) return 'typing'
return ''
}
return (
<Dropdown menu={{ items: getTopicMenuItems }} trigger={['contextMenu']}>
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
onDoubleClick={() => {
if (editingTopicId === topic.id && topicEdit.isEditing) return
setEditingTopicId(topic.id)
topicEdit.startEdit(topic.name)
}}
style={{
borderRadius,
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
}}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
<TopicNameContainer>
{editingTopicId === topic.id && topicEdit.isEditing ? (
<TopicEditInput
ref={topicEdit.inputRef}
value={topicEdit.editValue}
onChange={topicEdit.handleInputChange}
onKeyDown={topicEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName className={getTopicNameClassName()} title={topicName}>
{topicName}
</TopicName>
)}
{!topic.pinned && (
<Tooltip
placement="bottom"
delay={700}
closeDelay={0}
content={
<div style={{ fontSize: '12px', opacity: 0.8, fontStyle: 'italic' }}>
{t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })}
</div>
}>
<MenuButton
className="menu"
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
handleConfirmDelete(topic, e)
} else if (deletingTopicId === topic.id) {
handleConfirmDelete(topic, e)
} else {
handleDeleteClick(topic.id, e)
}
}}
onDoubleClick={(e) => e.stopPropagation()}>
{deletingTopicId === topic.id ? (
<DeleteIcon size={14} color="var(--color-error)" style={{ pointerEvents: 'none' }} />
) : (
<XIcon size={14} color="var(--color-text-3)" style={{ pointerEvents: 'none' }} />
)}
</MenuButton>
</Tooltip>
)}
{topic.pinned && (
<MenuButton className="pin">
<PinIcon size={14} color="var(--color-text-3)" />
</MenuButton>
)}
</TopicNameContainer>
{topicPrompt && (
<TopicPromptText className="prompt" title={fullTopicPrompt}>
{fullTopicPrompt}
</TopicPromptText>
)}
{showTopicTime && <TopicTime className="time">{dayjs(topic.createdAt).format('MM/DD HH:mm')}</TopicTime>}
</TopicListItem>
</Dropdown>
)
}}
</DraggableVirtualList>
)
const TopicsTab: FC<Props> = (props) => {
const { chat } = useRuntime()
const { activeTopicOrSession } = chat
if (activeTopicOrSession === 'topic') {
return <Topics {...props} />
}
if (activeTopicOrSession === 'session') {
return <SessionsTab />
}
return 'Not a valid state.'
}
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 AddTopicButton = styled.div`
display: flex;
align-items: center;
gap: 6px;
width: calc(100% - 10px);
padding: 7px 12px;
margin-bottom: 8px;
background: transparent;
color: var(--color-text-2);
font-size: 13px;
border-radius: var(--list-item-border-radius);
cursor: pointer;
transition: all 0.2s;
margin-top: -5px;
&:hover {
background-color: var(--color-list-item-hover);
color: var(--color-text-1);
}
.anticon {
font-size: 12px;
}
`
const TopicPromptText = styled.div`
color: var(--color-text-2);
font-size: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
~ .prompt-text {
margin-top: 10px;
}
`
const TopicTime = styled.div`
color: var(--color-text-3);
font-size: 11px;
`
const MenuButton = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 20px;
min-height: 20px;
.anticon {
font-size: 12px;
}
`
export default Topics
export default TopicsTab
@@ -0,0 +1,101 @@
import { Button, Chip, cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
interface AgentItemProps {
agent: AgentEntity
isActive: boolean
onDelete: (agent: AgentEntity) => void
onPress: () => void
}
const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) => {
const { t } = useTranslation()
const { sessions } = useSessions(agent.id)
return (
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name flex w-full justify-between" title={agent.name ?? agent.id}>
<AgentLabel agent={agent} />
{isActive && (
<Chip
variant="bordered"
size="sm"
radius="full"
className="aspect-square h-5 w-5 items-center justify-center border-[0.5px] bg-background text-[10px]">
{sessions.length}
</Chip>
)}
</AssistantNameRow>
</ButtonContainer>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
key="edit"
onClick={async () => {
// onOpen()
await 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>
{/* <AgentModal isOpen={isOpen} onClose={onClose} agent={agent} /> */}
</>
)
}
const ButtonContainer: React.FC<React.ComponentProps<typeof Button>> = ({ className, children, ...props }) => (
<Button
{...props}
className={cn(
'relative mb-2 flex h-[37px] flex-row justify-between p-2.5',
'rounded-[var(--list-item-border-radius)]',
'border-[0.5px] border-transparent',
'w-[calc(var(--assistants-width)_-_20px)]',
'bg-transparent hover:bg-[var(--color-list-item)] hover:shadow-sm',
'cursor-pointer',
className?.includes('active') && 'bg-[var(--color-list-item)] shadow-sm',
className
)}>
{children}
</Button>
)
const AssistantNameRow: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
{...props}
className={cn('text-[13px] text-[var(--color-text)]', 'flex flex-row items-center gap-2', className)}
/>
)
export default memo(AgentItem)
@@ -0,0 +1,39 @@
import { Alert } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { useTranslation } from 'react-i18next'
import { Agents } from './Agents'
import { SectionName } from './SectionName'
const ALERT_KEY = 'enable_api_server_to_use_agent'
export const AgentSection = () => {
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow } = useRuntime()
const dispatch = useAppDispatch()
if (!apiServer.enabled) {
if (iknow[ALERT_KEY]) return null
return (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)
}
return (
<div className="agents-tab mb-2 h-full w-full">
<SectionName name={t('common.agent_other')} />
<Agents />
</div>
)
}
@@ -0,0 +1,71 @@
import { Alert, Button, Spinner } from '@heroui/react'
import { AgentModal } from '@renderer/components/Popups/agent/AgentModal'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useAgentSessionInitializer } from '@renderer/hooks/agents/useAgentSessionInitializer'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
import { Plus } from 'lucide-react'
import { FC, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AgentItem from './AgentItem'
interface AssistantsTabProps {}
export const Agents: FC<AssistantsTabProps> = () => {
const { agents, deleteAgent, isLoading, error } = useAgents()
const { t } = useTranslation()
const { chat } = useRuntime()
const { activeAgentId } = chat
const { initializeAgentSession } = useAgentSessionInitializer()
const dispatch = useAppDispatch()
const setActiveAgentId = useCallback(
async (id: string) => {
dispatch(setActiveAgentIdAction(id))
// Initialize the session for this agent
await initializeAgentSession(id)
},
[dispatch, initializeAgentSession]
)
useEffect(() => {
if (!isLoading && agents.length > 0 && !activeAgentId) {
setActiveAgentId(agents[0].id)
}
}, [isLoading, agents, activeAgentId, setActiveAgentId])
return (
<>
{isLoading && <Spinner />}
{error && <Alert color="danger" title={t('agent.list.error.failed')} />}
{!isLoading &&
!error &&
agents.map((agent) => (
<AgentItem
key={agent.id}
agent={agent}
isActive={agent.id === activeAgentId}
onDelete={() => deleteAgent(agent.id)}
onPress={() => {
setActiveAgentId(agent.id)
}}
/>
))}
<AgentModal
trigger={{
content: (
<Button
onPress={(e) => e.continuePropagation()}
startContent={<Plus size={16} className="mr-1 shrink-0 translate-x-[-2px]" />}
className="w-full justify-start bg-transparent text-foreground-500 hover:bg-[var(--color-list-item)]">
{t('agent.add.title')}
</Button>
)
}}
/>
</>
)
}
@@ -8,6 +8,8 @@ import { useTags } from '@renderer/hooks/useTags'
import AssistantSettingsPopup from '@renderer/pages/settings/AssistantSettings'
import { getDefaultModel } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { useAppDispatch } from '@renderer/store'
import { setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import type { Assistant } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils'
import { hasTopicPendingRequests } from '@renderer/utils/queue'
@@ -43,7 +45,7 @@ interface AssistantItemProps {
onSwitch: (assistant: Assistant) => void
onDelete: (assistant: Assistant) => void
onCreateDefaultAssistant: () => void
addAgent: (agent: any) => void
addPreset: (agent: any) => void
copyAssistant: (assistant: Assistant) => void
onTagClick?: (tag: string) => void
handleSortByChange?: (sortType: AssistantTabSortType) => void
@@ -55,7 +57,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
sortBy,
onSwitch,
onDelete,
addAgent,
addPreset,
copyAssistant,
handleSortByChange
}) => {
@@ -70,6 +72,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
const { assistants, updateAssistants } = useAssistants()
const [isPending, setIsPending] = useState(false)
const dispatch = useAppDispatch()
useEffect(() => {
if (isActive) {
@@ -97,7 +100,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
allTags,
assistants,
updateAssistants,
addAgent,
addPreset,
copyAssistant,
onSwitch,
onDelete,
@@ -114,7 +117,7 @@ const AssistantItem: FC<AssistantItemProps> = ({
allTags,
assistants,
updateAssistants,
addAgent,
addPreset,
copyAssistant,
onSwitch,
onDelete,
@@ -134,7 +137,8 @@ const AssistantItem: FC<AssistantItemProps> = ({
}
}
onSwitch(assistant)
}, [clickAssistantToShowTopic, onSwitch, assistant, topicPosition])
dispatch(setActiveTopicOrSessionAction('topic'))
}, [clickAssistantToShowTopic, onSwitch, assistant, dispatch, topicPosition])
const assistantName = useMemo(() => assistant.name || t('chat.default.name'), [assistant.name, t])
const fullAssistantName = useMemo(
@@ -255,7 +259,7 @@ function getMenuItems({
allTags,
assistants,
updateAssistants,
addAgent,
addPreset,
copyAssistant,
onSwitch,
onDelete,
@@ -303,10 +307,10 @@ function getMenuItems({
key: 'save-to-agent',
icon: <Save size={14} />,
onClick: async () => {
const agent = omit(assistant, ['model', 'emoji'])
agent.id = uuid()
agent.type = 'agent'
addAgent(agent)
const preset = omit(assistant, ['model', 'emoji'])
preset.id = uuid()
preset.type = 'agent'
addPreset(preset)
window.toast.success(t('assistants.save.success'))
}
},
@@ -392,6 +396,7 @@ const Container = styled.div`
border-radius: var(--list-item-border-radius);
border: 0.5px solid transparent;
width: calc(var(--assistants-width) - 20px);
cursor: pointer;
&:hover {
background-color: var(--color-list-item-hover);

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