Refactor desktop i18n field copy into nested structures
This commit is contained in:
parent
f18a9dbefc
commit
b1b89f843e
@ -2,11 +2,12 @@ import { useRef } from 'react'
|
|||||||
|
|
||||||
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
const ICONS: Record<'files' | 'session', string> = {
|
||||||
files: { icon: 'cloud-upload', label: 'Drop files to attach' },
|
files: 'cloud-upload',
|
||||||
session: { icon: 'comment-discussion', label: 'Drop to link this chat' }
|
session: 'comment-discussion'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,13 +18,16 @@ const COPY: Record<'files' | 'session', { icon: string; label: string }> = {
|
|||||||
* fade-out so the label doesn't blank.
|
* fade-out so the label doesn't blank.
|
||||||
*/
|
*/
|
||||||
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
export function ChatDropOverlay({ kind }: { kind: DragKind }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const lastKind = useRef<'files' | 'session'>('files')
|
const lastKind = useRef<'files' | 'session'>('files')
|
||||||
|
|
||||||
if (kind) {
|
if (kind) {
|
||||||
lastKind.current = kind
|
lastKind.current = kind
|
||||||
}
|
}
|
||||||
|
|
||||||
const { icon, label } = COPY[kind ?? lastKind.current]
|
const resolvedKind = kind ?? lastKind.current
|
||||||
|
const icon = ICONS[resolvedKind]
|
||||||
|
const label = resolvedKind === 'files' ? t.composer.dropFiles : t.composer.dropSession
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
|
// Braille spinner frames — reads as a tiny ASCII loader in monospace.
|
||||||
@ -9,6 +10,7 @@ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '
|
|||||||
// backend (lazily spawned). Keeps the last profile name through the fade-out so
|
// backend (lazily spawned). Keeps the last profile name through the fade-out so
|
||||||
// the label doesn't blank. Purely visual — pointer-events-none.
|
// the label doesn't blank. Purely visual — pointer-events-none.
|
||||||
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [frame, setFrame] = useState(0)
|
const [frame, setFrame] = useState(0)
|
||||||
const [label, setLabel] = useState<null | string>(profile)
|
const [label, setLabel] = useState<null | string>(profile)
|
||||||
|
|
||||||
@ -38,7 +40,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
<div className="flex items-center gap-2 bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 font-mono text-[0.8125rem] text-foreground shadow-composer">
|
||||||
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
<span className="w-3 text-(--ui-accent)">{FRAMES[frame]}</span>
|
||||||
Waking up {label}…
|
{t.composer.wakingProfile(label ?? '')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1532,7 +1532,7 @@ export function ChatBar({
|
|||||||
{queueEdit && editingQueuedPrompt && (
|
{queueEdit && editingQueuedPrompt && (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
|
||||||
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
|
||||||
Editing queued turn in composer
|
{t.composer.editingQueuedInComposer}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@ -1541,14 +1541,14 @@ export function ChatBar({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="h-6 rounded-md px-2 text-[0.68rem]"
|
className="h-6 rounded-md px-2 text-[0.68rem]"
|
||||||
onClick={() => exitQueuedEdit('save')}
|
onClick={() => exitQueuedEdit('save')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Save
|
{t.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback } from 'react'
|
|||||||
|
|
||||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||||
import {
|
import {
|
||||||
addComposerAttachment,
|
addComposerAttachment,
|
||||||
@ -193,9 +194,11 @@ const attachToMain = (attachment: ComposerAttachment) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const addTextToDraft = useCallback((text: string) => {
|
const addTextToDraft = useCallback((text: string) => {
|
||||||
requestComposerInsert(text, { mode: 'block' })
|
requestComposerInsert(text, { mode: 'block' })
|
||||||
}, [])
|
}, [copy.imagePreviewFailed])
|
||||||
|
|
||||||
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
|
const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
@ -300,7 +303,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Image preview failed')
|
notifyError(err, copy.imagePreviewFailed)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -322,28 +325,28 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
|
const savedPath = await window.hermesDesktop?.saveImageBuffer(data, blobExtension(blob))
|
||||||
|
|
||||||
if (!savedPath) {
|
if (!savedPath) {
|
||||||
notify({ kind: 'error', title: 'Image attach', message: 'Failed to write image to disk.' })
|
notify({ kind: 'error', title: copy.imageAttach, message: copy.imageWriteFailed })
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return attachImagePath(savedPath)
|
return attachImagePath(savedPath)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Image attach failed')
|
notifyError(err, copy.imageAttachFailed)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[attachImagePath]
|
[attachImagePath, copy.imageAttach, copy.imageAttachFailed, copy.imageWriteFailed]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pickImages = useCallback(async () => {
|
const pickImages = useCallback(async () => {
|
||||||
const paths = await window.hermesDesktop?.selectPaths({
|
const paths = await window.hermesDesktop?.selectPaths({
|
||||||
title: 'Attach images',
|
title: copy.attachImages,
|
||||||
defaultPath: currentCwd || undefined,
|
defaultPath: currentCwd || undefined,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: 'Images',
|
name: t.composer.images,
|
||||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
|
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -356,7 +359,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
await attachImagePath(path)
|
await attachImagePath(path)
|
||||||
}
|
}
|
||||||
}, [attachImagePath, currentCwd])
|
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
|
||||||
|
|
||||||
const pasteClipboardImage = useCallback(async () => {
|
const pasteClipboardImage = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -365,8 +368,8 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
if (!path) {
|
if (!path) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Clipboard',
|
title: copy.clipboard,
|
||||||
message: 'No image found in clipboard'
|
message: copy.noClipboardImage
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -374,9 +377,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
|
|
||||||
await attachImagePath(path)
|
await attachImagePath(path)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Clipboard paste failed')
|
notifyError(err, copy.clipboardPasteFailed)
|
||||||
}
|
}
|
||||||
}, [attachImagePath])
|
}, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
|
||||||
|
|
||||||
const attachContextFolderPath = useCallback(
|
const attachContextFolderPath = useCallback(
|
||||||
(folderPath: string) => {
|
(folderPath: string) => {
|
||||||
@ -477,12 +480,12 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!attached && lastFailure) {
|
if (!attached && lastFailure) {
|
||||||
notify({ kind: 'warning', title: 'Drop files', message: lastFailure })
|
notify({ kind: 'warning', title: copy.dropFiles, message: lastFailure })
|
||||||
}
|
}
|
||||||
|
|
||||||
return attached
|
return attached
|
||||||
},
|
},
|
||||||
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
|
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath, copy.dropFiles]
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeAttachment = useCallback(
|
const removeAttachment = useCallback(
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
|||||||
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
import { requestComposerInsert } from '@/app/chat/composer/focus'
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify } from '@/store/notifications'
|
import { notify } from '@/store/notifications'
|
||||||
@ -74,6 +75,9 @@ interface ConsoleRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.preview.console
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -81,7 +85,7 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
|||||||
selected && 'border-border/60 bg-accent/40'
|
selected && 'border-border/60 bg-accent/40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tip label={selected ? 'Deselect entry' : 'Select entry'}>
|
<Tip label={selected ? copy.deselect : copy.select}>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
'mt-0.5 text-left uppercase opacity-70 transition-colors hover:opacity-100',
|
||||||
@ -108,13 +112,13 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
errorMessage="Could not copy console output"
|
errorMessage={copy.copyFailed}
|
||||||
iconClassName="size-3"
|
iconClassName="size-3"
|
||||||
label="Copy this entry"
|
label={copy.copyEntry}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
text={copyText}
|
text={copyText}
|
||||||
/>
|
/>
|
||||||
<Tip label="Send this entry to chat">
|
<Tip label={copy.sendEntry}>
|
||||||
<button
|
<button
|
||||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
@ -129,12 +133,13 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
|
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const logCount = useStore(consoleState.$logCount)
|
const logCount = useStore(consoleState.$logCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PanelBottom />
|
<PanelBottom />
|
||||||
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
|
{logCount > 0 && <span className="sr-only">{t.preview.console.messages(logCount)}</span>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -152,6 +157,8 @@ export function PreviewConsolePanel({
|
|||||||
consoleState,
|
consoleState,
|
||||||
startConsoleResize
|
startConsoleResize
|
||||||
}: PreviewConsolePanelProps) {
|
}: PreviewConsolePanelProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.preview.console
|
||||||
const consoleHeight = useStore(consoleState.$height)
|
const consoleHeight = useStore(consoleState.$height)
|
||||||
const logs = useStore(consoleState.$logs)
|
const logs = useStore(consoleState.$logs)
|
||||||
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
const selectedLogIds = useStore(consoleState.$selectedLogIds)
|
||||||
@ -188,14 +195,14 @@ export function PreviewConsolePanel({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
|
const block = [copy.promptHeader, '```', ...entries.map(formatLogLine), '```'].join('\n')
|
||||||
|
|
||||||
requestComposerInsert(block, { mode: 'block', target: 'main' })
|
requestComposerInsert(block, { mode: 'block', target: 'main' })
|
||||||
consoleState.clearSelection()
|
consoleState.clearSelection()
|
||||||
notify({
|
notify({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: 'Sent to chat',
|
title: copy.sentTitle,
|
||||||
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
|
message: copy.sentMessage(entries.length)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,7 +212,7 @@ export function PreviewConsolePanel({
|
|||||||
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="Resize preview console"
|
aria-label={copy.resize}
|
||||||
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
||||||
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
|
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
|
||||||
onPointerDown={startConsoleResize}
|
onPointerDown={startConsoleResize}
|
||||||
@ -216,10 +223,10 @@ export function PreviewConsolePanel({
|
|||||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
||||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||||
<PanelBottom className="size-3.5" />
|
<PanelBottom className="size-3.5" />
|
||||||
Preview Console
|
{copy.title}
|
||||||
{selectedLogIds.size > 0 && (
|
{selectedLogIds.size > 0 && (
|
||||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||||
{selectedLogIds.size} selected
|
{copy.selected(selectedLogIds.size)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -231,18 +238,18 @@ export function PreviewConsolePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Send className="size-3" />
|
<Send className="size-3" />
|
||||||
Send to chat
|
{copy.sendToChat}
|
||||||
</button>
|
</button>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||||
disabled={sendableLogs.length === 0}
|
disabled={sendableLogs.length === 0}
|
||||||
errorMessage="Could not copy console output"
|
errorMessage={copy.copyFailed}
|
||||||
iconClassName="size-3"
|
iconClassName="size-3"
|
||||||
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
label={visibleSelection.length > 0 ? copy.copySelected : copy.copyAll}
|
||||||
text={() => formatConsoleEntries(sendableLogs)}
|
text={() => formatConsoleEntries(sendableLogs)}
|
||||||
>
|
>
|
||||||
Copy
|
{copy.copy}
|
||||||
</CopyButton>
|
</CopyButton>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||||
@ -251,7 +258,7 @@ export function PreviewConsolePanel({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3" />
|
<Trash2 className="size-3" />
|
||||||
Clear
|
{copy.clear}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -275,7 +282,7 @@ export function PreviewConsolePanel({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
<div className="py-2 text-muted-foreground/70">{copy.empty}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Streamdown } from 'streamdown'
|
|||||||
|
|
||||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||||
import { PageLoader } from '@/components/page-loader'
|
import { PageLoader } from '@/components/page-loader'
|
||||||
|
import { translateNow, useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { PreviewTarget } from '@/store/preview'
|
import type { PreviewTarget } from '@/store/preview'
|
||||||
|
|
||||||
@ -143,7 +144,7 @@ function filePathForTarget(target: PreviewTarget) {
|
|||||||
|
|
||||||
function formatBytes(bytes: number | undefined) {
|
function formatBytes(bytes: number | undefined) {
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
return 'unknown size'
|
return translateNow('preview.unknownSize')
|
||||||
}
|
}
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
@ -296,6 +297,8 @@ function MarkdownPreview({ text }: { text: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||||
<button
|
<button
|
||||||
@ -303,7 +306,7 @@ function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: ()
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{asSource ? 'PREVIEW' : 'SOURCE'}
|
{asSource ? t.preview.renderedPreview : t.preview.source}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -330,6 +333,7 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||||
@ -373,7 +377,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
|||||||
key={line}
|
key={line}
|
||||||
onClick={event => handleLineClick(event, line)}
|
onClick={event => handleLineClick(event, line)}
|
||||||
onDragStart={event => handleDragStart(event, line)}
|
onDragStart={event => handleDragStart(event, line)}
|
||||||
title="Click to select · shift-click to extend · drag to composer"
|
title={t.preview.sourceLineTitle}
|
||||||
>
|
>
|
||||||
{line}
|
{line}
|
||||||
</div>
|
</div>
|
||||||
@ -408,6 +412,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||||
const [forcePreview, setForcePreview] = useState(false)
|
const [forcePreview, setForcePreview] = useState(false)
|
||||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||||
@ -482,11 +487,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return <PageLoader label="Loading preview" />
|
return <PageLoader label={t.preview.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
|
return <PreviewEmptyState body={state.error} title={t.preview.unavailable} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -501,11 +506,11 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
<PreviewEmptyState
|
<PreviewEmptyState
|
||||||
body={
|
body={
|
||||||
binary
|
binary
|
||||||
? `Previewing ${target.label} may show unreadable text.`
|
? t.preview.binaryBody(target.label)
|
||||||
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
|
: t.preview.largeBody(target.label, formatBytes(size))
|
||||||
}
|
}
|
||||||
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
|
primaryAction={{ label: t.preview.previewAnyway, onClick: () => setForcePreview(true) }}
|
||||||
title={binary ? 'This looks like a binary file' : 'This file is large'}
|
title={binary ? t.preview.binaryTitle : t.preview.largeTitle}
|
||||||
tone="warning"
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -532,7 +537,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
<div className="h-full overflow-auto bg-transparent">
|
<div className="h-full overflow-auto bg-transparent">
|
||||||
{state.truncated && (
|
{state.truncated && (
|
||||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||||
Showing first 512 KB.
|
{t.preview.truncated}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||||
@ -547,8 +552,8 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PreviewEmptyState
|
<PreviewEmptyState
|
||||||
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
|
body={t.preview.noInlineBody(target.mimeType || '')}
|
||||||
title="No inline preview"
|
title={t.preview.noInlineTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
|
|
||||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { Bug } from '@/lib/icons'
|
import { Bug } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@ -46,18 +47,18 @@ interface PreviewLoadErrorState {
|
|||||||
const FILE_RELOAD_DEBOUNCE_MS = 200
|
const FILE_RELOAD_DEBOUNCE_MS = 200
|
||||||
const SERVER_RESTART_TIMEOUT_MS = 45_000
|
const SERVER_RESTART_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
function loadErrorTitle(error: PreviewLoadErrorState): string {
|
function loadErrorTitle(error: PreviewLoadErrorState, copy: Translations['preview']['web']): string {
|
||||||
const description = error.description.toLowerCase()
|
const description = error.description.toLowerCase()
|
||||||
|
|
||||||
if (description.includes('module script') || description.includes('mime type')) {
|
if (description.includes('module script') || description.includes('mime type')) {
|
||||||
return 'Preview app failed to boot'
|
return copy.appFailedToBoot
|
||||||
}
|
}
|
||||||
|
|
||||||
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
|
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
|
||||||
return 'Server not found'
|
return copy.serverNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Preview failed to load'
|
return copy.failedToLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
function isModuleMimeError(message: string): boolean {
|
function isModuleMimeError(message: string): boolean {
|
||||||
@ -79,6 +80,9 @@ function PreviewLoadError({
|
|||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
restarting?: boolean
|
restarting?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.preview.web
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PreviewEmptyState
|
<PreviewEmptyState
|
||||||
body={
|
body={
|
||||||
@ -98,17 +102,17 @@ function PreviewLoadError({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
consoleHeight={consoleHeight}
|
consoleHeight={consoleHeight}
|
||||||
primaryAction={{ label: 'Try again', onClick: onRetry }}
|
primaryAction={{ label: copy.tryAgain, onClick: onRetry }}
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
onRestartServer
|
onRestartServer
|
||||||
? {
|
? {
|
||||||
disabled: restarting,
|
disabled: restarting,
|
||||||
label: restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server',
|
label: restarting ? copy.restarting : copy.askRestart,
|
||||||
onClick: onRestartServer
|
onClick: onRestartServer
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
title={loadErrorTitle(error)}
|
title={loadErrorTitle(error, copy)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -122,6 +126,8 @@ export function PreviewPane({
|
|||||||
setTitlebarToolGroup,
|
setTitlebarToolGroup,
|
||||||
target
|
target
|
||||||
}: PreviewPaneProps) {
|
}: PreviewPaneProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.preview.web
|
||||||
const [consoleState] = useState(() => createPreviewConsoleState())
|
const [consoleState] = useState(() => createPreviewConsoleState())
|
||||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||||
const consoleShouldStickRef = useRef(true)
|
const consoleShouldStickRef = useRef(true)
|
||||||
@ -239,23 +245,23 @@ export function PreviewPane({
|
|||||||
|
|
||||||
appendConsoleEntry({
|
appendConsoleEntry({
|
||||||
level: 1,
|
level: 1,
|
||||||
message: `Hermes is looking for a preview server to restart (${taskId})`
|
message: copy.lookingRestart(taskId)
|
||||||
})
|
})
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
title: 'Restarting preview server',
|
title: copy.restartingTitle,
|
||||||
message: 'Hermes is working in the background. Watch the preview console for progress.',
|
message: copy.restartingMessage,
|
||||||
durationMs: 4000
|
durationMs: 4000
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendConsoleEntry({
|
appendConsoleEntry({
|
||||||
level: 2,
|
level: 2,
|
||||||
message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}`
|
message: copy.startRestartFailed(error instanceof Error ? error.message : String(error))
|
||||||
})
|
})
|
||||||
notifyError(error, 'Server restart failed')
|
notifyError(error, copy.restartFailed)
|
||||||
}
|
}
|
||||||
}, [appendConsoleEntry, consoleState, currentUrl, onRestartServer])
|
}, [appendConsoleEntry, consoleState, copy, currentUrl, onRestartServer])
|
||||||
|
|
||||||
const toggleDevTools = useCallback(() => {
|
const toggleDevTools = useCallback(() => {
|
||||||
const webview = webviewRef.current
|
const webview = webviewRef.current
|
||||||
@ -287,14 +293,14 @@ export function PreviewPane({
|
|||||||
active: consoleOpen,
|
active: consoleOpen,
|
||||||
icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />,
|
icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />,
|
||||||
id: `${TITLEBAR_GROUP_ID}-console`,
|
id: `${TITLEBAR_GROUP_ID}-console`,
|
||||||
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
|
label: consoleOpen ? copy.hideConsole : copy.showConsole,
|
||||||
onSelect: () => consoleState.setOpen(open => !open)
|
onSelect: () => consoleState.setOpen(open => !open)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
active: devtoolsOpen,
|
active: devtoolsOpen,
|
||||||
icon: <Bug />,
|
icon: <Bug />,
|
||||||
id: `${TITLEBAR_GROUP_ID}-devtools`,
|
id: `${TITLEBAR_GROUP_ID}-devtools`,
|
||||||
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
|
label: devtoolsOpen ? copy.hideDevTools : copy.openDevTools,
|
||||||
onSelect: toggleDevTools
|
onSelect: toggleDevTools
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -304,7 +310,7 @@ export function PreviewPane({
|
|||||||
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
|
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
|
||||||
|
|
||||||
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
|
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
|
||||||
}, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
|
}, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!consoleOpen) {
|
if (!consoleOpen) {
|
||||||
@ -343,29 +349,27 @@ export function PreviewPane({
|
|||||||
previewServerRestart.status === 'running'
|
previewServerRestart.status === 'running'
|
||||||
? previewServerRestart.message
|
? previewServerRestart.message
|
||||||
: previewServerRestart.status === 'complete'
|
: previewServerRestart.status === 'complete'
|
||||||
? `Hermes finished restarting the preview server${
|
? copy.finishedRestarting(previewServerRestart.message)
|
||||||
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
|
: copy.failedRestarting(previewServerRestart.message || copy.unknownError)
|
||||||
}`
|
|
||||||
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (previewServerRestart.status === 'complete') {
|
if (previewServerRestart.status === 'complete') {
|
||||||
reloadPreview()
|
reloadPreview()
|
||||||
notify({
|
notify({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: 'Preview server restarted',
|
title: copy.restartedTitle,
|
||||||
message: previewServerRestart.message?.slice(0, 160) || 'Reloading the preview now.',
|
message: previewServerRestart.message?.slice(0, 160) || copy.reloadingNow,
|
||||||
durationMs: 3500
|
durationMs: 3500
|
||||||
})
|
})
|
||||||
} else if (previewServerRestart.status === 'error') {
|
} else if (previewServerRestart.status === 'error') {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Preview restart failed',
|
title: copy.restartFailedTitle,
|
||||||
message: previewServerRestart.message?.slice(0, 200) || 'Hermes could not restart the server.',
|
message: previewServerRestart.message?.slice(0, 200) || copy.restartFailedMessage,
|
||||||
durationMs: 6000
|
durationMs: 6000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url])
|
}, [appendConsoleEntry, copy, currentUrl, previewServerRestart, reloadPreview, target.url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!restartingServer || !previewServerRestart) {
|
if (!restartingServer || !previewServerRestart) {
|
||||||
@ -375,14 +379,11 @@ export function PreviewPane({
|
|||||||
const taskId = previewServerRestart.taskId
|
const taskId = previewServerRestart.taskId
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
failPreviewServerRestart(
|
failPreviewServerRestart(taskId, copy.stillWorking)
|
||||||
taskId,
|
|
||||||
'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.'
|
|
||||||
)
|
|
||||||
}, SERVER_RESTART_TIMEOUT_MS)
|
}, SERVER_RESTART_TIMEOUT_MS)
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [previewServerRestart, restartingServer])
|
}, [copy.stillWorking, previewServerRestart, restartingServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reloadRequest === lastReloadRequestRef.current) {
|
if (reloadRequest === lastReloadRequestRef.current) {
|
||||||
@ -397,10 +398,10 @@ export function PreviewPane({
|
|||||||
|
|
||||||
appendConsoleEntry({
|
appendConsoleEntry({
|
||||||
level: 1,
|
level: 1,
|
||||||
message: 'Workspace changed, reloading preview'
|
message: copy.workspaceReloading
|
||||||
})
|
})
|
||||||
reloadPreview()
|
reloadPreview()
|
||||||
}, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
|
}, [appendConsoleEntry, copy.workspaceReloading, reloadPreview, reloadRequest, target.kind])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -432,8 +433,8 @@ export function PreviewPane({
|
|||||||
level: 1,
|
level: 1,
|
||||||
message:
|
message:
|
||||||
changedCount === 1
|
changedCount === 1
|
||||||
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
|
? copy.fileChanged(compactUrl(changedUrl))
|
||||||
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
|
: copy.filesChanged(changedCount, compactUrl(changedUrl))
|
||||||
})
|
})
|
||||||
|
|
||||||
reloadPreview()
|
reloadPreview()
|
||||||
@ -471,7 +472,7 @@ export function PreviewPane({
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
appendConsoleEntry({
|
appendConsoleEntry({
|
||||||
level: 2,
|
level: 2,
|
||||||
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
|
message: copy.watchFailed(error instanceof Error ? error.message : String(error))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -487,7 +488,7 @@ export function PreviewPane({
|
|||||||
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [appendConsoleEntry, reloadPreview, target.kind, target.url])
|
}, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current
|
const host = hostRef.current
|
||||||
@ -535,8 +536,7 @@ export function PreviewPane({
|
|||||||
|
|
||||||
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
|
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
|
||||||
setLoadError({
|
setLoadError({
|
||||||
description:
|
description: copy.moduleMimeDescription,
|
||||||
'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
|
|
||||||
url: webview.getURL?.() || target.url
|
url: webview.getURL?.() || target.url
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -567,13 +567,11 @@ export function PreviewPane({
|
|||||||
|
|
||||||
appendConsoleEntry({
|
appendConsoleEntry({
|
||||||
level: 3,
|
level: 3,
|
||||||
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
|
message: copy.loadFailedConsole(errorCode, detail.errorDescription || detail.validatedURL || copy.unknownError)
|
||||||
detail.errorDescription || detail.validatedURL || 'unknown error'
|
|
||||||
}`
|
|
||||||
})
|
})
|
||||||
setLoadError({
|
setLoadError({
|
||||||
code: errorCode,
|
code: errorCode,
|
||||||
description: detail.errorDescription || 'The preview page could not be reached.',
|
description: detail.errorDescription || copy.unreachableDescription,
|
||||||
url: detail.validatedURL || webview.getURL?.() || target.url
|
url: detail.validatedURL || webview.getURL?.() || target.url
|
||||||
})
|
})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -600,7 +598,7 @@ export function PreviewPane({
|
|||||||
webview.removeEventListener('did-stop-loading', onStop)
|
webview.removeEventListener('did-stop-loading', onStop)
|
||||||
webview.remove()
|
webview.remove()
|
||||||
}
|
}
|
||||||
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
|
}, [appendConsoleEntry, consoleState, copy, isWebPreview, target.url])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
|
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent text-muted-foreground">
|
||||||
@ -608,14 +606,14 @@ export function PreviewPane({
|
|||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Tip label={`Open ${currentUrl}`}>
|
<Tip label={copy.openTarget(currentUrl)}>
|
||||||
<a
|
<a
|
||||||
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
className="pointer-events-auto inline max-w-full truncate text-left text-xs font-medium text-foreground underline-offset-4 decoration-current/20 transition-colors hover:text-primary hover:underline"
|
||||||
href={currentUrl}
|
href={currentUrl}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{previewLabel || 'Preview'}
|
{previewLabel || copy.fallbackTitle}
|
||||||
</a>
|
</a>
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { translateNow, useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
$rightRailActiveTabId,
|
$rightRailActiveTabId,
|
||||||
@ -48,10 +49,11 @@ function tabLabelFor(target: PreviewTarget): string {
|
|||||||
const value = target.label || target.path || target.source || target.url
|
const value = target.label || target.path || target.source || target.url
|
||||||
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
|
const tail = value.split(/[\\/]/).filter(Boolean).at(-1)
|
||||||
|
|
||||||
return tail || value || 'Preview'
|
return tail || value || translateNow('preview.tab')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const previewReloadRequest = useStore($previewReloadRequest)
|
const previewReloadRequest = useStore($previewReloadRequest)
|
||||||
const activeTabId = useStore($rightRailActiveTabId)
|
const activeTabId = useStore($rightRailActiveTabId)
|
||||||
const filePreviewTabs = useStore($filePreviewTabs)
|
const filePreviewTabs = useStore($filePreviewTabs)
|
||||||
@ -59,10 +61,10 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||||||
|
|
||||||
const tabs = useMemo<readonly RailTab[]>(
|
const tabs = useMemo<readonly RailTab[]>(
|
||||||
() => [
|
() => [
|
||||||
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: 'Preview', target: previewTarget } as RailTab] : []),
|
...(previewTarget ? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab] : []),
|
||||||
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
|
||||||
],
|
],
|
||||||
[filePreviewTabs, previewTarget]
|
[filePreviewTabs, previewTarget, t.preview.tab]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
|
||||||
@ -134,7 +136,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
aria-label={`Close ${tab.label}`}
|
aria-label={t.preview.closeTab(tab.label)}
|
||||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||||
onClick={() => closeRightRailTab(tab.id)}
|
onClick={() => closeRightRailTab(tab.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@ -146,7 +148,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="Close preview pane"
|
aria-label={t.preview.closePane}
|
||||||
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
className="mr-1.5 grid size-6 shrink-0 self-center place-items-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring group-hover/rail-tabs:opacity-100 [-webkit-app-region:no-drag]"
|
||||||
onClick={closeRightRail}
|
onClick={closeRightRail}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -92,18 +92,18 @@ const NEW_SESSION_KBD: readonly string[] =
|
|||||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
id: 'new-session',
|
id: 'new-session',
|
||||||
label: 'New session',
|
label: '',
|
||||||
icon: props => <Codicon name="robot" {...props} />,
|
icon: props => <Codicon name="robot" {...props} />,
|
||||||
action: 'new-session'
|
action: 'new-session'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'skills',
|
id: 'skills',
|
||||||
label: 'Skills & Tools',
|
label: '',
|
||||||
icon: props => <Codicon name="symbol-misc" {...props} />,
|
icon: props => <Codicon name="symbol-misc" {...props} />,
|
||||||
route: SKILLS_ROUTE
|
route: SKILLS_ROUTE
|
||||||
},
|
},
|
||||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
{ id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
{ id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||||
]
|
]
|
||||||
|
|
||||||
const WORKSPACE_PAGE = 5
|
const WORKSPACE_PAGE = 5
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
import { PROFILE_SWATCHES, profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -84,6 +85,8 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
|||||||
// profile users see only the "+" (create their first profile); everything else
|
// profile users see only the "+" (create their first profile); everything else
|
||||||
// appears once a second profile exists.
|
// appears once a second profile exists.
|
||||||
export function ProfileRail() {
|
export function ProfileRail() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.profiles
|
||||||
const profiles = useStore($profiles)
|
const profiles = useStore($profiles)
|
||||||
const scope = useStore($profileScope)
|
const scope = useStore($profileScope)
|
||||||
const gatewayProfile = useStore($activeGatewayProfile)
|
const gatewayProfile = useStore($activeGatewayProfile)
|
||||||
@ -187,11 +190,11 @@ export function ProfileRail() {
|
|||||||
<ProfilePill
|
<ProfilePill
|
||||||
active={isAll || onDefault}
|
active={isAll || onDefault}
|
||||||
glyph={isAll ? 'layers' : 'home'}
|
glyph={isAll ? 'layers' : 'home'}
|
||||||
label={onDefault ? 'Show all profiles' : `Switch to ${defaultProfile.name}`}
|
label={onDefault ? p.showAllProfiles : p.switchToProfile(defaultProfile.name)}
|
||||||
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
|
onSelect={() => (onDefault ? setShowAllProfiles(true) : selectProfile(defaultProfile.name))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ProfilePill active={isAll} glyph="layers" label="All profiles" onSelect={() => setShowAllProfiles(true)} />
|
<ProfilePill active={isAll} glyph="layers" label={p.allProfiles} onSelect={() => setShowAllProfiles(true)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Single-profile: the active default's home icon next to the create +. */}
|
{/* Single-profile: the active default's home icon next to the create +. */}
|
||||||
@ -233,9 +236,9 @@ export function ProfileRail() {
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tip label="New profile">
|
<Tip label={p.newProfile}>
|
||||||
<button
|
<button
|
||||||
aria-label="New profile"
|
aria-label={p.newProfile}
|
||||||
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
className="grid size-5 shrink-0 place-items-center rounded-[3px] text-(--ui-text-tertiary) opacity-55 transition hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100"
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
@ -246,7 +249,7 @@ export function ProfileRail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{multiProfile && (
|
{multiProfile && (
|
||||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
<ProfilePill active={false} glyph="ellipsis" label={p.manageProfiles} onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||||
@ -328,6 +331,8 @@ const LONG_PRESS_MS = 450
|
|||||||
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
// context-menu triggers via nested asChild Slots, so a single element keeps the
|
||||||
// dnd listeners, hover tip, and right-click menu.
|
// dnd listeners, hover tip, and right-click menu.
|
||||||
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, onSelect }: ProfileSquareProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.profiles
|
||||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const pressTimer = useRef<null | number>(null)
|
const pressTimer = useRef<null | number>(null)
|
||||||
@ -436,27 +441,27 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
|||||||
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
|
{/* The rail sits at the very bottom, so pad off the chrome (esp. the
|
||||||
statusbar) — Radix then flips the menu up instead of squishing it. */}
|
statusbar) — Radix then flips the menu up instead of squishing it. */}
|
||||||
<ContextMenuContent
|
<ContextMenuContent
|
||||||
aria-label={`Actions for ${label}`}
|
aria-label={p.actionsFor(label)}
|
||||||
className="w-40"
|
className="w-40"
|
||||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||||
>
|
>
|
||||||
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
<ContextMenuItem onSelect={() => setPickerOpen(true)}>
|
||||||
<Codicon name="symbol-color" size="0.875rem" />
|
<Codicon name="symbol-color" size="0.875rem" />
|
||||||
<span>Color…</span>
|
<span>{p.color}</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={onRename}>
|
<ContextMenuItem onSelect={onRename}>
|
||||||
<Codicon name="edit" size="0.875rem" />
|
<Codicon name="edit" size="0.875rem" />
|
||||||
<span>Rename</span>
|
<span>{p.rename}</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
<ContextMenuItem className="text-destructive focus:text-destructive" onSelect={onDelete} variant="destructive">
|
||||||
<Codicon name="trash" size="0.875rem" />
|
<Codicon name="trash" size="0.875rem" />
|
||||||
<span>Delete</span>
|
<span>{t.common.delete}</span>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
aria-label={`Color for ${label}`}
|
aria-label={p.colorFor(label)}
|
||||||
className="w-auto p-2"
|
className="w-auto p-2"
|
||||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||||
side="top"
|
side="top"
|
||||||
@ -464,7 +469,7 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
|||||||
<div className="grid grid-cols-6 gap-1.5">
|
<div className="grid grid-cols-6 gap-1.5">
|
||||||
{PROFILE_SWATCHES.map(swatch => (
|
{PROFILE_SWATCHES.map(swatch => (
|
||||||
<button
|
<button
|
||||||
aria-label={`Set color ${swatch}`}
|
aria-label={p.setColor(swatch)}
|
||||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||||
key={swatch}
|
key={swatch}
|
||||||
onClick={() => pickColor(swatch)}
|
onClick={() => pickColor(swatch)}
|
||||||
@ -483,7 +488,7 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Codicon name="sync" size="0.75rem" />
|
<Codicon name="sync" size="0.75rem" />
|
||||||
Auto
|
{p.autoColor}
|
||||||
</button>
|
</button>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
|
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@ -92,48 +93,60 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
|||||||
title: sessionTitle(session)
|
title: sessionTitle(session)
|
||||||
})
|
})
|
||||||
|
|
||||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
type NonConfigSettingsLabel =
|
||||||
|
| 'about'
|
||||||
|
| 'archivedChats'
|
||||||
|
| 'gateway'
|
||||||
|
| 'keysSettings'
|
||||||
|
| 'keysTools'
|
||||||
|
| 'mcp'
|
||||||
|
| 'providerAccounts'
|
||||||
|
| 'providerApiKeys'
|
||||||
|
|
||||||
|
const NON_CONFIG_SETTINGS: ReadonlyArray<{
|
||||||
|
icon: IconComponent
|
||||||
|
keywords?: string[]
|
||||||
|
labelKey: NonConfigSettingsLabel
|
||||||
|
tab: string
|
||||||
|
}> = [
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
|
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
|
||||||
label: 'Providers',
|
labelKey: 'providerAccounts',
|
||||||
tab: 'providers&pview=accounts'
|
tab: 'providers&pview=accounts'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||||
label: 'Provider API keys',
|
labelKey: 'providerApiKeys',
|
||||||
tab: 'providers&pview=keys'
|
tab: 'providers&pview=keys'
|
||||||
},
|
},
|
||||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
{ icon: Globe, keywords: ['connection', 'messaging'], labelKey: 'gateway', tab: 'gateway' },
|
||||||
{
|
{
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
|
keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'],
|
||||||
label: 'Tools & Keys',
|
labelKey: 'keysTools',
|
||||||
tab: 'keys&kview=tools'
|
tab: 'keys&kview=tools'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
|
keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'],
|
||||||
label: 'Tools & Keys settings',
|
labelKey: 'keysSettings',
|
||||||
tab: 'keys&kview=settings'
|
tab: 'keys&kview=settings'
|
||||||
},
|
},
|
||||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
{ icon: Wrench, keywords: ['servers', 'tools'], labelKey: 'mcp', tab: 'mcp' },
|
||||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
{ icon: Archive, keywords: ['history', 'archived'], labelKey: 'archivedChats', tab: 'sessions' },
|
||||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
{ icon: Info, keywords: ['version', 'about'], labelKey: 'about', tab: 'about' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; label: string; mode: ThemeMode }> = [
|
const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
|
||||||
{ icon: Sun, label: 'Light', mode: 'light' },
|
{ icon: Sun, mode: 'light' },
|
||||||
{ icon: Moon, label: 'Dark', mode: 'dark' },
|
{ icon: Moon, mode: 'dark' },
|
||||||
{ icon: Monitor, label: 'System', mode: 'system' }
|
{ icon: Monitor, mode: 'system' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function fieldLabel(key: string): string {
|
|
||||||
return FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
|
const { t } = useI18n()
|
||||||
const open = useStore($commandPaletteOpen)
|
const open = useStore($commandPaletteOpen)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||||
@ -180,52 +193,61 @@ export function CommandPalette() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||||
|
const settingsSectionLabel = useCallback(
|
||||||
|
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
||||||
|
[t.settings.sections]
|
||||||
|
)
|
||||||
|
const configFieldLabel = useCallback(
|
||||||
|
(key: string) => t.settings.fieldLabels[key] ?? FIELD_LABELS[key] ?? prettyName(key.split('.').pop() ?? key),
|
||||||
|
[t.settings.fieldLabels]
|
||||||
|
)
|
||||||
|
|
||||||
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
const baseGroups = useMemo<PaletteGroup[]>(() => {
|
||||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||||
|
const cc = t.commandCenter
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
heading: 'Go to',
|
heading: cc.goTo,
|
||||||
items: [
|
items: [
|
||||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: 'New session', run: go(NEW_CHAT_ROUTE) },
|
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
|
||||||
{ icon: Settings, id: 'nav-settings', label: 'Settings', run: go(SETTINGS_ROUTE) },
|
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
|
||||||
{
|
{
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
id: 'nav-skills',
|
id: 'nav-skills',
|
||||||
keywords: ['tools', 'toolsets'],
|
keywords: ['tools', 'toolsets'],
|
||||||
label: 'Skills & Tools',
|
label: cc.nav.skills.title,
|
||||||
run: go(SKILLS_ROUTE)
|
run: go(SKILLS_ROUTE)
|
||||||
},
|
},
|
||||||
{ icon: MessageCircle, id: 'nav-messaging', label: 'Messaging', run: go(MESSAGING_ROUTE) },
|
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
|
||||||
{ icon: Package, id: 'nav-artifacts', label: 'Artifacts', run: go(ARTIFACTS_ROUTE) },
|
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
|
||||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: 'Cron', run: go(CRON_ROUTE) },
|
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
|
||||||
{ icon: Users, id: 'nav-profiles', label: 'Profiles', run: go(PROFILES_ROUTE) },
|
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||||
{ icon: Cpu, id: 'nav-agents', label: 'Agents', run: go(AGENTS_ROUTE) }
|
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Command Center',
|
heading: cc.commandCenter,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: Archive,
|
icon: Archive,
|
||||||
id: 'cc-sessions',
|
id: 'cc-sessions',
|
||||||
keywords: ['command center', 'sessions', 'pin'],
|
keywords: ['command center', 'sessions', 'pin'],
|
||||||
label: 'Sessions',
|
label: cc.sections.sessions,
|
||||||
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
|
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
id: 'cc-system',
|
id: 'cc-system',
|
||||||
keywords: ['command center', 'system', 'status', 'logs'],
|
keywords: ['command center', 'system', 'status', 'logs'],
|
||||||
label: 'System',
|
label: cc.sections.system,
|
||||||
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
|
run: go(`${COMMAND_CENTER_ROUTE}?section=system`)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
id: 'cc-usage',
|
id: 'cc-usage',
|
||||||
keywords: ['command center', 'usage', 'tokens', 'cost'],
|
keywords: ['command center', 'usage', 'tokens', 'cost'],
|
||||||
label: 'Usage',
|
label: cc.sections.usage,
|
||||||
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
|
run: go(`${COMMAND_CENTER_ROUTE}?section=usage`)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -234,45 +256,45 @@ export function CommandPalette() {
|
|||||||
// Declared before Settings: cmdk keeps group order, so this keeps the
|
// Declared before Settings: cmdk keeps group order, so this keeps the
|
||||||
// theme/mode pickers on top for "theme"/"color" queries instead of
|
// theme/mode pickers on top for "theme"/"color" queries instead of
|
||||||
// buried under a fuzzy Settings match.
|
// buried under a fuzzy Settings match.
|
||||||
heading: 'Appearance',
|
heading: cc.appearance,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
id: 'appearance-theme',
|
id: 'appearance-theme',
|
||||||
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
|
keywords: ['theme', 'appearance', 'color', 'palette', 'skin', 'dark', 'light', 'look'],
|
||||||
label: 'Change theme…',
|
label: cc.changeTheme,
|
||||||
to: 'theme'
|
to: 'theme'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Sun,
|
icon: Sun,
|
||||||
id: 'appearance-mode',
|
id: 'appearance-mode',
|
||||||
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
||||||
label: 'Change color mode…',
|
label: cc.changeColorMode,
|
||||||
to: 'color-mode'
|
to: 'color-mode'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: 'Settings',
|
heading: cc.settings,
|
||||||
items: [
|
items: [
|
||||||
...SECTIONS.map(section => ({
|
...SECTIONS.map(section => ({
|
||||||
icon: section.icon,
|
icon: section.icon,
|
||||||
id: `set-config-${section.id}`,
|
id: `set-config-${section.id}`,
|
||||||
keywords: ['settings', section.label],
|
keywords: ['settings', section.label, settingsSectionLabel(section)],
|
||||||
label: section.label,
|
label: settingsSectionLabel(section),
|
||||||
run: go(settingsTab(`config:${section.id}`))
|
run: go(settingsTab(`config:${section.id}`))
|
||||||
})),
|
})),
|
||||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
id: `set-${entry.tab}`,
|
id: `set-${entry.tab}`,
|
||||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||||
label: entry.label,
|
label: t.settings.nav[entry.labelKey],
|
||||||
run: go(settingsTab(entry.tab))
|
run: go(settingsTab(entry.tab))
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}, [go])
|
}, [go, settingsSectionLabel, t])
|
||||||
|
|
||||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||||
// chats) only surface once the user types — otherwise they'd bury the
|
// chats) only surface once the user types — otherwise they'd bury the
|
||||||
@ -286,7 +308,7 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
if (sessions.length > 0) {
|
if (sessions.length > 0) {
|
||||||
result.push({
|
result.push({
|
||||||
heading: 'Sessions',
|
heading: t.commandCenter.sections.sessions,
|
||||||
items: sessions.map(session => ({
|
items: sessions.map(session => ({
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
id: `session-${session.id}`,
|
id: `session-${session.id}`,
|
||||||
@ -301,17 +323,17 @@ export function CommandPalette() {
|
|||||||
section.keys.map(key => ({
|
section.keys.map(key => ({
|
||||||
icon: section.icon,
|
icon: section.icon,
|
||||||
id: `field-${key}`,
|
id: `field-${key}`,
|
||||||
keywords: ['settings', key, section.label],
|
keywords: ['settings', key, section.label, settingsSectionLabel(section)],
|
||||||
label: `${section.label}: ${fieldLabel(key)}`,
|
label: `${settingsSectionLabel(section)}: ${configFieldLabel(key)}`,
|
||||||
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
|
run: go(`${SETTINGS_ROUTE}?tab=config:${section.id}&field=${encodeURIComponent(key)}`)
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
result.push({ heading: 'Settings fields', items: fieldItems })
|
result.push({ heading: t.commandCenter.settingsFields, items: fieldItems })
|
||||||
|
|
||||||
if (mcpServers.length > 0) {
|
if (mcpServers.length > 0) {
|
||||||
result.push({
|
result.push({
|
||||||
heading: 'MCP servers',
|
heading: t.commandCenter.mcpServers,
|
||||||
items: mcpServers.map(name => ({
|
items: mcpServers.map(name => ({
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
id: `mcp-${name}`,
|
id: `mcp-${name}`,
|
||||||
@ -324,7 +346,7 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
if (archivedSessions.length > 0) {
|
if (archivedSessions.length > 0) {
|
||||||
result.push({
|
result.push({
|
||||||
heading: 'Archived chats',
|
heading: t.commandCenter.archivedChats,
|
||||||
items: archivedSessions.map(session => ({
|
items: archivedSessions.map(session => ({
|
||||||
icon: Archive,
|
icon: Archive,
|
||||||
id: `archived-${session.id}`,
|
id: `archived-${session.id}`,
|
||||||
@ -336,7 +358,7 @@ export function CommandPalette() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}, [archivedSessions, go, mcpServers, search, sessions])
|
}, [archivedSessions, configFieldLabel, go, mcpServers, search, sessions, settingsSectionLabel, t])
|
||||||
|
|
||||||
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
|
const groups = useMemo(() => [...baseGroups, ...searchGroups], [baseGroups, searchGroups])
|
||||||
|
|
||||||
@ -345,13 +367,13 @@ export function CommandPalette() {
|
|||||||
const subPages = useMemo<Record<string, PalettePage>>(
|
const subPages = useMemo<Record<string, PalettePage>>(
|
||||||
() => ({
|
() => ({
|
||||||
theme: {
|
theme: {
|
||||||
title: 'Theme',
|
title: t.settings.appearance.themeTitle,
|
||||||
placeholder: 'Choose a theme…',
|
placeholder: t.settings.appearance.themeDesc,
|
||||||
// Skins aren't inherently light/dark — the same skin renders in either
|
// Skins aren't inherently light/dark — the same skin renders in either
|
||||||
// mode. Group by appearance so picking an entry sets skin + mode at
|
// mode. Group by appearance so picking an entry sets skin + mode at
|
||||||
// once, and keep the palette open so each pick previews live.
|
// once, and keep the palette open so each pick previews live.
|
||||||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
groups: (['light', 'dark'] as const).map(groupMode => ({
|
||||||
heading: groupMode === 'light' ? 'Light' : 'Dark',
|
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
||||||
items: availableThemes.map(theme => ({
|
items: availableThemes.map(theme => ({
|
||||||
active: themeName === theme.name && resolvedMode === groupMode,
|
active: themeName === theme.name && resolvedMode === groupMode,
|
||||||
icon: groupMode === 'light' ? Sun : Moon,
|
icon: groupMode === 'light' ? Sun : Moon,
|
||||||
@ -367,30 +389,30 @@ export function CommandPalette() {
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
'color-mode': {
|
'color-mode': {
|
||||||
title: 'Color mode',
|
title: t.settings.appearance.colorMode,
|
||||||
placeholder: 'Choose color mode…',
|
placeholder: t.settings.appearance.colorModeDesc,
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
heading: 'Color mode',
|
heading: t.settings.appearance.colorMode,
|
||||||
items: THEME_MODES.map(entry => ({
|
items: THEME_MODES.map(entry => ({
|
||||||
active: mode === entry.mode,
|
active: mode === entry.mode,
|
||||||
icon: entry.icon,
|
icon: entry.icon,
|
||||||
id: `mode-${entry.mode}`,
|
id: `mode-${entry.mode}`,
|
||||||
keepOpen: true,
|
keepOpen: true,
|
||||||
keywords: ['appearance', 'brightness', entry.label],
|
keywords: ['appearance', 'brightness', t.settings.modeOptions[entry.mode].label],
|
||||||
label: entry.label,
|
label: t.settings.modeOptions[entry.mode].label,
|
||||||
run: () => setMode(entry.mode)
|
run: () => setMode(entry.mode)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[availableThemes, mode, resolvedMode, setMode, setTheme, themeName]
|
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activePage = page ? subPages[page] : null
|
const activePage = page ? subPages[page] : null
|
||||||
const visibleGroups = activePage ? activePage.groups : groups
|
const visibleGroups = activePage ? activePage.groups : groups
|
||||||
const placeholder = activePage ? activePage.placeholder : 'Search commands and settings...'
|
const placeholder = activePage ? activePage.placeholder : t.commandCenter.searchPlaceholder
|
||||||
|
|
||||||
const handleSelect = (item: PaletteItem) => {
|
const handleSelect = (item: PaletteItem) => {
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
@ -415,7 +437,7 @@ export function CommandPalette() {
|
|||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Title className="sr-only">Command palette</DialogPrimitive.Title>
|
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||||
<Command className="bg-transparent" loop>
|
<Command className="bg-transparent" loop>
|
||||||
{activePage && (
|
{activePage && (
|
||||||
<button
|
<button
|
||||||
@ -424,7 +446,7 @@ export function CommandPalette() {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-3.5" />
|
<ChevronLeft className="size-3.5" />
|
||||||
<span>Back</span>
|
<span>{t.commandCenter.back}</span>
|
||||||
<span className="text-muted-foreground/50">/</span>
|
<span className="text-muted-foreground/50">/</span>
|
||||||
<span className="font-medium text-foreground">{activePage.title}</span>
|
<span className="font-medium text-foreground">{activePage.title}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -448,7 +470,7 @@ export function CommandPalette() {
|
|||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||||
{visibleGroups.map(group => (
|
{visibleGroups.map(group => (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||||
|
|||||||
@ -199,7 +199,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
setDesktopBootStep({
|
setDesktopBootStep({
|
||||||
phase: 'renderer.boot',
|
phase: 'renderer.boot',
|
||||||
message: 'Starting desktop connection',
|
message: translateNow('boot.steps.startingDesktopConnection'),
|
||||||
progress: 6
|
progress: 6
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -280,13 +280,13 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
const offExit = desktop.onBackendExit(() => {
|
const offExit = desktop.onBackendExit(() => {
|
||||||
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
|
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
|
||||||
failDesktopBoot('Hermes background process exited during startup.')
|
failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
|
||||||
}
|
}
|
||||||
|
|
||||||
notify({
|
notify({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'Backend stopped',
|
title: translateNow('boot.errors.backendStopped'),
|
||||||
message: 'Hermes background process exited.',
|
message: translateNow('boot.errors.backgroundExited'),
|
||||||
durationMs: 0
|
durationMs: 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -301,7 +301,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
setDesktopBootStep({
|
setDesktopBootStep({
|
||||||
phase: 'renderer.gateway.connect',
|
phase: 'renderer.gateway.connect',
|
||||||
message: 'Connecting live desktop gateway',
|
message: translateNow('boot.steps.connectingGateway'),
|
||||||
progress: 95
|
progress: 95
|
||||||
})
|
})
|
||||||
publish(conn)
|
publish(conn)
|
||||||
@ -332,7 +332,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
setDesktopBootStep({
|
setDesktopBootStep({
|
||||||
phase: 'renderer.config',
|
phase: 'renderer.config',
|
||||||
message: 'Loading Hermes settings',
|
message: translateNow('boot.steps.loadingSettings'),
|
||||||
progress: 97
|
progress: 97
|
||||||
})
|
})
|
||||||
await callbacksRef.current.refreshHermesConfig()
|
await callbacksRef.current.refreshHermesConfig()
|
||||||
@ -343,7 +343,7 @@ export function useGatewayBoot({
|
|||||||
|
|
||||||
setDesktopBootStep({
|
setDesktopBootStep({
|
||||||
phase: 'renderer.sessions',
|
phase: 'renderer.sessions',
|
||||||
message: 'Loading recent sessions',
|
message: translateNow('boot.steps.loadingSessions'),
|
||||||
progress: 99
|
progress: 99
|
||||||
})
|
})
|
||||||
await callbacksRef.current.refreshSessions()
|
await callbacksRef.current.refreshSessions()
|
||||||
@ -353,7 +353,7 @@ export function useGatewayBoot({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
failDesktopBoot(message)
|
failDesktopBoot(message)
|
||||||
notifyError(err, 'Desktop boot failed')
|
notifyError(err, translateNow('boot.errors.desktopBootFailed'))
|
||||||
setSessionsLoading(false)
|
setSessionsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,141 +66,20 @@ const trimEdits = (edits: Record<string, string>): Record<string, string> =>
|
|||||||
.filter(([, v]) => v)
|
.filter(([, v]) => v)
|
||||||
)
|
)
|
||||||
|
|
||||||
const FIELD_COPY: Record<string, { advanced?: boolean; help?: string; label: string; placeholder?: string }> = {
|
const FIELD_COPY: Record<string, { advanced?: boolean }> = {
|
||||||
TELEGRAM_BOT_TOKEN: {
|
TELEGRAM_PROXY: { advanced: true },
|
||||||
label: 'Bot token',
|
DISCORD_REPLY_TO_MODE: { advanced: true },
|
||||||
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
DISCORD_ALLOW_ALL_USERS: { advanced: true },
|
||||||
placeholder: 'Paste Telegram bot token'
|
DISCORD_HOME_CHANNEL: { advanced: true },
|
||||||
},
|
DISCORD_HOME_CHANNEL_NAME: { advanced: true },
|
||||||
TELEGRAM_ALLOWED_USERS: {
|
BLUEBUBBLES_ALLOW_ALL_USERS: { advanced: true },
|
||||||
label: 'Allowed Telegram user IDs',
|
MATTERMOST_ALLOW_ALL_USERS: { advanced: true },
|
||||||
help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
|
MATTERMOST_HOME_CHANNEL: { advanced: true },
|
||||||
},
|
QQ_ALLOW_ALL_USERS: { advanced: true },
|
||||||
TELEGRAM_PROXY: {
|
QQBOT_HOME_CHANNEL: { advanced: true },
|
||||||
label: 'Proxy URL',
|
QQBOT_HOME_CHANNEL_NAME: { advanced: true },
|
||||||
help: 'Only needed on networks where Telegram is blocked.',
|
WHATSAPP_ENABLED: { advanced: true },
|
||||||
advanced: true
|
WHATSAPP_MODE: { advanced: true }
|
||||||
},
|
|
||||||
DISCORD_BOT_TOKEN: {
|
|
||||||
label: 'Bot token',
|
|
||||||
help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
|
|
||||||
},
|
|
||||||
DISCORD_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed Discord user IDs',
|
|
||||||
help: 'Recommended. Comma-separated Discord user IDs.'
|
|
||||||
},
|
|
||||||
DISCORD_REPLY_TO_MODE: {
|
|
||||||
label: 'Reply style',
|
|
||||||
help: 'first, all, or off.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
DISCORD_ALLOW_ALL_USERS: {
|
|
||||||
label: 'Allow all Discord users',
|
|
||||||
help: 'Development only. When true, anyone can DM the bot without an allowlist.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
DISCORD_HOME_CHANNEL: {
|
|
||||||
label: 'Home channel ID',
|
|
||||||
help: 'Channel where the bot sends proactive messages (cron output, reminders).',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
DISCORD_HOME_CHANNEL_NAME: {
|
|
||||||
label: 'Home channel name',
|
|
||||||
help: 'Display name for the home channel in logs and status output.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
BLUEBUBBLES_ALLOW_ALL_USERS: {
|
|
||||||
label: 'Allow all iMessage users',
|
|
||||||
help: 'When true, skip the BlueBubbles allowlist.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
MATTERMOST_ALLOW_ALL_USERS: {
|
|
||||||
label: 'Allow all Mattermost users',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
MATTERMOST_HOME_CHANNEL: {
|
|
||||||
label: 'Home channel',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
QQ_ALLOW_ALL_USERS: {
|
|
||||||
label: 'Allow all QQ users',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
QQBOT_HOME_CHANNEL: {
|
|
||||||
label: 'QQ home channel',
|
|
||||||
help: 'Default channel or group for cron delivery.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
QQBOT_HOME_CHANNEL_NAME: {
|
|
||||||
label: 'QQ home channel name',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
SLACK_BOT_TOKEN: {
|
|
||||||
label: 'Slack bot token',
|
|
||||||
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
|
|
||||||
placeholder: 'Paste Slack bot token'
|
|
||||||
},
|
|
||||||
SLACK_APP_TOKEN: {
|
|
||||||
label: 'Slack app token',
|
|
||||||
help: 'Use the app-level token required for Socket Mode.',
|
|
||||||
placeholder: 'Paste Slack app token'
|
|
||||||
},
|
|
||||||
SLACK_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed Slack user IDs',
|
|
||||||
help: 'Recommended. Comma-separated Slack user IDs.'
|
|
||||||
},
|
|
||||||
MATTERMOST_URL: {
|
|
||||||
label: 'Server URL',
|
|
||||||
placeholder: 'https://mattermost.example.com'
|
|
||||||
},
|
|
||||||
MATTERMOST_TOKEN: {
|
|
||||||
label: 'Bot token'
|
|
||||||
},
|
|
||||||
MATTERMOST_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed user IDs',
|
|
||||||
help: 'Recommended. Comma-separated Mattermost user IDs.'
|
|
||||||
},
|
|
||||||
MATRIX_HOMESERVER: {
|
|
||||||
label: 'Homeserver URL',
|
|
||||||
placeholder: 'https://matrix.org'
|
|
||||||
},
|
|
||||||
MATRIX_ACCESS_TOKEN: {
|
|
||||||
label: 'Access token'
|
|
||||||
},
|
|
||||||
MATRIX_USER_ID: {
|
|
||||||
label: 'Bot user ID',
|
|
||||||
placeholder: '@hermes:example.org'
|
|
||||||
},
|
|
||||||
MATRIX_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed Matrix user IDs',
|
|
||||||
help: 'Recommended. Comma-separated user IDs in @user:server format.'
|
|
||||||
},
|
|
||||||
SIGNAL_HTTP_URL: {
|
|
||||||
label: 'Signal bridge URL',
|
|
||||||
placeholder: 'http://127.0.0.1:8080',
|
|
||||||
help: 'URL of a running signal-cli REST bridge.'
|
|
||||||
},
|
|
||||||
SIGNAL_ACCOUNT: {
|
|
||||||
label: 'Phone number',
|
|
||||||
help: 'The number registered with your signal-cli bridge.'
|
|
||||||
},
|
|
||||||
SIGNAL_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed Signal users',
|
|
||||||
help: 'Recommended. Comma-separated Signal identifiers.'
|
|
||||||
},
|
|
||||||
WHATSAPP_ENABLED: {
|
|
||||||
label: 'Enable WhatsApp bridge',
|
|
||||||
help: 'Set automatically by the toggle below. Leave alone unless you know you need it.',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
WHATSAPP_MODE: {
|
|
||||||
label: 'Bridge mode',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
WHATSAPP_ALLOWED_USERS: {
|
|
||||||
label: 'Allowed WhatsApp users',
|
|
||||||
help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
||||||
@ -208,9 +87,9 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) {
|
|||||||
const localized = m.fieldCopy[field.key] || {}
|
const localized = m.fieldCopy[field.key] || {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: localized.label || copy.label || field.prompt || field.key,
|
label: localized.label || field.prompt || field.key,
|
||||||
help: localized.help || copy.help || field.description,
|
help: localized.help || field.description,
|
||||||
placeholder: localized.placeholder || copy.placeholder || field.prompt,
|
placeholder: localized.placeholder || field.prompt,
|
||||||
advanced: Boolean(copy.advanced || field.advanced)
|
advanced: Boolean(copy.advanced || field.advanced)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { translateNow } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ interface OverlayViewProps {
|
|||||||
export function OverlayView({
|
export function OverlayView({
|
||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
closeLabel = 'Close',
|
closeLabel = translateNow('common.close'),
|
||||||
contentClassName,
|
contentClassName,
|
||||||
headerContent,
|
headerContent,
|
||||||
rootClassName
|
rootClassName
|
||||||
|
|||||||
@ -7,14 +7,12 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle } from '@/lib/icons'
|
import { AlertTriangle } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||||
|
|
||||||
export const PROFILE_NAME_HINT =
|
|
||||||
'Lowercase letters, digits, hyphens, and underscores. Must start with a letter or digit.'
|
|
||||||
|
|
||||||
export function isValidProfileName(name: string): boolean {
|
export function isValidProfileName(name: string): boolean {
|
||||||
return PROFILE_NAME_RE.test(name.trim())
|
return PROFILE_NAME_RE.test(name.trim())
|
||||||
}
|
}
|
||||||
@ -31,6 +29,8 @@ export function CreateProfileDialog({
|
|||||||
onCreated?: (name: string) => Promise<void> | void
|
onCreated?: (name: string) => Promise<void> | void
|
||||||
open: boolean
|
open: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.profiles
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||||
const [soul, setSoul] = useState('')
|
const [soul, setSoul] = useState('')
|
||||||
@ -57,7 +57,7 @@ export function CreateProfileDialog({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!trimmed || invalid) {
|
if (!trimmed || invalid) {
|
||||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ export function CreateProfileDialog({
|
|||||||
window.setTimeout(onClose, 800)
|
window.setTimeout(onClose, 800)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create profile')
|
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,16 +85,14 @@ export function CreateProfileDialog({
|
|||||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New profile</DialogTitle>
|
<DialogTitle>{p.newProfile}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{p.createDesc}</DialogDescription>
|
||||||
Profiles are independent Hermes environments: separate config, skills, and SOUL.md.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||||
Name
|
{p.nameLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
@ -105,7 +103,7 @@ export function CreateProfileDialog({
|
|||||||
value={name}
|
value={name}
|
||||||
/>
|
/>
|
||||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||||
{PROFILE_NAME_HINT}
|
{p.nameHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -116,22 +114,20 @@ export function CreateProfileDialog({
|
|||||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||||
/>
|
/>
|
||||||
<span className="grid gap-0.5 leading-snug">
|
<span className="grid gap-0.5 leading-snug">
|
||||||
<span className="text-sm font-medium">Clone from default</span>
|
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||||
Copy config, skills, and SOUL.md from your default profile.
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||||
SOUL.md <span className="font-normal text-muted-foreground">— optional</span>
|
SOUL.md <span className="font-normal text-muted-foreground">- {p.soulOptional}</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="min-h-28 font-mono text-xs leading-5"
|
className="min-h-28 font-mono text-xs leading-5"
|
||||||
id="new-profile-soul"
|
id="new-profile-soul"
|
||||||
onChange={event => setSoul(event.target.value)}
|
onChange={event => setSoul(event.target.value)}
|
||||||
placeholder={`The system prompt / persona for this profile.\nLeave blank to keep the ${cloneFromDefault ? 'cloned' : 'empty'} default.`}
|
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||||
value={soul}
|
value={soul}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -145,10 +141,10 @@ export function CreateProfileDialog({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy || !trimmed || invalid} type="submit">
|
<Button disabled={busy || !trimmed || invalid} type="submit">
|
||||||
<ActionStatus busy="Creating…" done="Created" idle="Create profile" state={status} />
|
<ActionStatus busy={p.creating} done={p.created} idle={p.createAction} state={status} />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||||
import { deleteProfile } from '@/hermes'
|
import { deleteProfile } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
|
import { $activeGatewayProfile, normalizeProfileKey, selectProfile, setActiveProfile } from '@/store/profile'
|
||||||
|
|
||||||
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
|
// Thin wrapper over ConfirmDialog: owns the deleteProfile call, inherits
|
||||||
@ -16,20 +17,26 @@ export function DeleteProfileDialog({
|
|||||||
onDeleted?: () => Promise<void> | void
|
onDeleted?: () => Promise<void> | void
|
||||||
open: boolean
|
open: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.profiles
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
busyLabel="Deleting…"
|
busyLabel={p.deleting}
|
||||||
confirmLabel="Delete"
|
confirmLabel={t.common.delete}
|
||||||
description={
|
description={
|
||||||
profile ? (
|
profile ? (
|
||||||
<>
|
<>
|
||||||
This will delete <span className="font-medium text-foreground">{profile.name}</span> and remove its{' '}
|
{p.deleteDescPrefix}
|
||||||
<span className="font-mono text-xs">{profile.path}</span> directory. This cannot be undone.
|
<span className="font-medium text-foreground">{profile.name}</span>
|
||||||
|
{p.deleteDescMid}
|
||||||
|
<span className="font-mono text-xs">{profile.path}</span>
|
||||||
|
{p.deleteDescSuffix}
|
||||||
</>
|
</>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
destructive
|
destructive
|
||||||
doneLabel="Deleted"
|
doneLabel={p.deleted}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
@ -52,7 +59,7 @@ export function DeleteProfileDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={open}
|
open={open}
|
||||||
title="Delete profile?"
|
title={p.deleteTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { renameProfile } from '@/hermes'
|
import { renameProfile } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle } from '@/lib/icons'
|
import { AlertTriangle } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import { isValidProfileName, PROFILE_NAME_HINT } from './create-profile-dialog'
|
import { isValidProfileName } from './create-profile-dialog'
|
||||||
|
|
||||||
// Self-contained rename (owns the renameProfile call) so every caller just
|
// Self-contained rename (owns the renameProfile call) so every caller just
|
||||||
// reacts via onRenamed. Unchanged name is a no-op close.
|
// reacts via onRenamed. Unchanged name is a no-op close.
|
||||||
@ -23,6 +24,8 @@ export function RenameProfileDialog({
|
|||||||
onRenamed?: (name: string) => Promise<void> | void
|
onRenamed?: (name: string) => Promise<void> | void
|
||||||
open: boolean
|
open: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.profiles
|
||||||
const [name, setName] = useState(currentName)
|
const [name, setName] = useState(currentName)
|
||||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||||
const [error, setError] = useState<null | string>(null)
|
const [error, setError] = useState<null | string>(null)
|
||||||
@ -52,7 +55,7 @@ export function RenameProfileDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!trimmed || invalid) {
|
if (!trimmed || invalid) {
|
||||||
setError(invalid ? `Invalid name. ${PROFILE_NAME_HINT}` : 'Name is required.')
|
setError(invalid ? p.invalidName(p.nameHint) : p.nameRequired)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -67,7 +70,7 @@ export function RenameProfileDialog({
|
|||||||
window.setTimeout(onClose, 800)
|
window.setTimeout(onClose, 800)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
setError(err instanceof Error ? err.message : 'Failed to rename profile')
|
setError(err instanceof Error ? err.message : p.failedRename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,17 +78,18 @@ export function RenameProfileDialog({
|
|||||||
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rename profile</DialogTitle>
|
<DialogTitle>{p.renameTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Renaming updates the profile directory and any wrapper scripts in{' '}
|
{p.renameDescPrefix}
|
||||||
<span className="font-mono">~/.local/bin</span>.
|
<span className="font-mono">~/.local/bin</span>
|
||||||
|
{p.renameDescSuffix}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form className="grid gap-3" onSubmit={handleSubmit}>
|
<form className="grid gap-3" onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||||
New name
|
{p.newNameLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
@ -95,7 +99,7 @@ export function RenameProfileDialog({
|
|||||||
value={name}
|
value={name}
|
||||||
/>
|
/>
|
||||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||||
{PROFILE_NAME_HINT}
|
{p.nameHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -108,10 +112,10 @@ export function RenameProfileDialog({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy || invalid || unchanged} type="submit">
|
<Button disabled={busy || invalid || unchanged} type="submit">
|
||||||
<ActionStatus busy="Renaming…" done="Renamed" idle="Rename" state={status} />
|
<ActionStatus busy={p.renaming} done={p.renamed} idle={p.rename} state={status} />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-
|
|||||||
import { PageLoader } from '@/components/page-loader'
|
import { PageLoader } from '@/components/page-loader'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { TreeNode } from './use-project-tree'
|
import type { TreeNode } from './use-project-tree'
|
||||||
@ -122,7 +123,9 @@ export function ProjectTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeSizingState() {
|
function TreeSizingState() {
|
||||||
return <PageLoader aria-label="Loading files" className="min-h-24 px-3" />
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
return <PageLoader aria-label={t.rightSidebar.loadingFiles} className="min-h-24 px-3" />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectTreeRow({
|
function ProjectTreeRow({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
|
|||||||
import { ErrorBoundary } from '@/components/error-boundary'
|
import { ErrorBoundary } from '@/components/error-boundary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
@ -29,15 +30,17 @@ interface RightSidebarPaneProps {
|
|||||||
interface RightSidebarTab {
|
interface RightSidebarTab {
|
||||||
icon: string
|
icon: string
|
||||||
id: RightSidebarTabId
|
id: RightSidebarTabId
|
||||||
label: string
|
labelKey: 'files' | 'terminal'
|
||||||
}
|
}
|
||||||
|
|
||||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||||
{ id: 'files', label: 'File system', icon: 'list-tree' },
|
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: 'terminal' }
|
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const r = t.rightSidebar
|
||||||
const activeTab = useStore($rightSidebarTab)
|
const activeTab = useStore($rightSidebarTab)
|
||||||
const terminalTakeover = useStore($terminalTakeover)
|
const terminalTakeover = useStore($terminalTakeover)
|
||||||
const panesFlipped = useStore($panesFlipped)
|
const panesFlipped = useStore($panesFlipped)
|
||||||
@ -50,7 +53,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
.split(/[\\/]+/)
|
.split(/[\\/]+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.pop() ?? currentCwd)
|
.pop() ?? currentCwd)
|
||||||
: 'No folder selected'
|
: r.noFolderSelected
|
||||||
|
|
||||||
const {
|
const {
|
||||||
collapseAll,
|
collapseAll,
|
||||||
@ -72,7 +75,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
defaultPath: hasCwd ? currentCwd : undefined,
|
defaultPath: hasCwd ? currentCwd : undefined,
|
||||||
directories: true,
|
directories: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
title: 'Change working directory'
|
title: r.changeCwdTitle
|
||||||
})
|
})
|
||||||
|
|
||||||
if (selected?.[0]) {
|
if (selected?.[0]) {
|
||||||
@ -85,12 +88,12 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||||
|
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
throw new Error(`Could not preview ${path}`)
|
throw new Error(r.couldNotPreview(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Preview unavailable')
|
notifyError(error, r.previewUnavailable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +101,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Right sidebar"
|
aria-label={r.aria}
|
||||||
className={cn(
|
className={cn(
|
||||||
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
|
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
|
||||||
panesFlipped
|
panesFlipped
|
||||||
@ -144,27 +147,34 @@ function RightSidebarChrome({
|
|||||||
branch: string
|
branch: string
|
||||||
tabs: readonly RightSidebarTab[]
|
tabs: readonly RightSidebarTab[]
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const r = t.rightSidebar
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||||
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
|
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => {
|
||||||
<Tip key={tab.id} label={tab.label}>
|
const label = r[tab.labelKey]
|
||||||
<Button
|
|
||||||
aria-label={tab.label}
|
return (
|
||||||
aria-pressed={tab.id === activeTab}
|
<Tip key={tab.id} label={label}>
|
||||||
className={cn(
|
<Button
|
||||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
aria-label={label}
|
||||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
aria-pressed={tab.id === activeTab}
|
||||||
)}
|
className={cn(
|
||||||
onClick={() => setRightSidebarTab(tab.id)}
|
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||||
size="icon-xs"
|
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||||
variant="ghost"
|
)}
|
||||||
>
|
onClick={() => setRightSidebarTab(tab.id)}
|
||||||
<Codicon name={tab.icon} size="0.875rem" />
|
size="icon-xs"
|
||||||
</Button>
|
variant="ghost"
|
||||||
</Tip>
|
>
|
||||||
))}
|
<Codicon name={tab.icon} size="0.875rem" />
|
||||||
|
</Button>
|
||||||
|
</Tip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{branch && (
|
{branch && (
|
||||||
@ -214,10 +224,13 @@ function FilesystemTab({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
openState
|
openState
|
||||||
}: FilesystemTabProps) {
|
}: FilesystemTabProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const r = t.rightSidebar
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
<div className="group/project-header flex min-h-0 flex-1 flex-col">
|
||||||
<RightSidebarSectionHeader>
|
<RightSidebarSectionHeader>
|
||||||
<Tip label={hasCwd ? `${cwd} — click to change folder` : 'Open a folder'}>
|
<Tip label={hasCwd ? r.folderTip(cwd) : r.openFolder}>
|
||||||
<button
|
<button
|
||||||
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
className="flex min-w-0 flex-1 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||||
onClick={() => void onChangeFolder()}
|
onClick={() => void onChangeFolder()}
|
||||||
@ -227,7 +240,7 @@ function FilesystemTab({
|
|||||||
</button>
|
</button>
|
||||||
</Tip>
|
</Tip>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Refresh tree"
|
aria-label={r.refreshTree}
|
||||||
className={HEADER_ACTION_CLASS}
|
className={HEADER_ACTION_CLASS}
|
||||||
disabled={!hasCwd || loading}
|
disabled={!hasCwd || loading}
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
@ -237,7 +250,7 @@ function FilesystemTab({
|
|||||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Open folder"
|
aria-label={r.openFolder}
|
||||||
className={HEADER_ACTION_CLASS}
|
className={HEADER_ACTION_CLASS}
|
||||||
onClick={() => void onChangeFolder()}
|
onClick={() => void onChangeFolder()}
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
@ -246,7 +259,7 @@ function FilesystemTab({
|
|||||||
<Codicon name="folder-opened" size="0.8125rem" />
|
<Codicon name="folder-opened" size="0.8125rem" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Collapse all folders"
|
aria-label={r.collapseAll}
|
||||||
className={HEADER_ACTION_REVEAL_CLASS}
|
className={HEADER_ACTION_REVEAL_CLASS}
|
||||||
disabled={!hasCwd || !canCollapse}
|
disabled={!hasCwd || !canCollapse}
|
||||||
onClick={onCollapseAll}
|
onClick={onCollapseAll}
|
||||||
@ -304,12 +317,15 @@ function FileTreeBody({
|
|||||||
onPreviewFile,
|
onPreviewFile,
|
||||||
openState
|
openState
|
||||||
}: FileTreeBodyProps) {
|
}: FileTreeBodyProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const r = t.rightSidebar
|
||||||
|
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
|
return <EmptyState body={r.noProjectBody} title={r.noProjectTitle} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
|
return <EmptyState body={r.unreadableBody(error)} title={r.unreadableTitle} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && data.length === 0) {
|
if (loading && data.length === 0) {
|
||||||
@ -317,20 +333,20 @@ function FileTreeBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <EmptyState body="This folder is empty." title="Empty" />
|
return <EmptyState body={r.emptyBody} title={r.emptyTitle} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={({ reset }) => (
|
fallback={({ reset }) => (
|
||||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
|
||||||
<EmptyState body="The file tree hit an error rendering this folder." title="Tree error" />
|
<EmptyState body={r.treeErrorBody} title={r.treeErrorTitle} />
|
||||||
<button
|
<button
|
||||||
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
className="text-[0.68rem] font-medium text-muted-foreground transition hover:text-foreground"
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Try again
|
{r.tryAgain}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -353,8 +369,10 @@ function FileTreeBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FileTreeLoadingState() {
|
function FileTreeLoadingState() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-label="Loading file tree" className="grid min-h-0 flex-1 place-items-center px-3" role="status">
|
<div aria-label={t.rightSidebar.loadingTree} className="grid min-h-0 flex-1 place-items-center px-3" role="status">
|
||||||
<Loader
|
<Loader
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-8 text-(--ui-text-tertiary)"
|
className="size-8 text-(--ui-text-tertiary)"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||||
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
|
||||||
@ -19,13 +20,14 @@ interface TerminalTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
|
||||||
cwd,
|
cwd,
|
||||||
onAddSelectionToChat
|
onAddSelectionToChat
|
||||||
})
|
})
|
||||||
|
|
||||||
const takeover = useStore($terminalTakeover)
|
const takeover = useStore($terminalTakeover)
|
||||||
const label = takeover ? 'Return to split view' : 'Focus terminal view'
|
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
|
||||||
|
|
||||||
const toggleTakeover = () => {
|
const toggleTakeover = () => {
|
||||||
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
// Pre-select the Terminal tab so the slot is ready to host us on return.
|
||||||
@ -77,7 +79,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Add to chat
|
{t.rightSidebar.addToChat}
|
||||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type MutableRefObject, useCallback } from 'react'
|
import { type MutableRefObject, useCallback } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
||||||
import type { SessionRuntimeInfo } from '@/types/hermes'
|
import type { SessionRuntimeInfo } from '@/types/hermes'
|
||||||
@ -17,6 +18,8 @@ export function useCwdActions({
|
|||||||
onSessionRuntimeInfo,
|
onSessionRuntimeInfo,
|
||||||
requestGateway
|
requestGateway
|
||||||
}: CwdActionsOptions) {
|
}: CwdActionsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const refreshProjectBranch = useCallback(
|
const refreshProjectBranch = useCallback(
|
||||||
async (cwd: string) => {
|
async (cwd: string) => {
|
||||||
const target = cwd.trim()
|
const target = cwd.trim()
|
||||||
@ -85,7 +88,7 @@ export function useCwdActions({
|
|||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
if (!message.includes('unknown method')) {
|
if (!message.includes('unknown method')) {
|
||||||
notifyError(err, 'Working directory change failed')
|
notifyError(err, copy.cwdChangeFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -94,12 +97,12 @@ export function useCwdActions({
|
|||||||
setCurrentBranch('')
|
setCurrentBranch('')
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Working directory staged',
|
title: copy.cwdStagedTitle,
|
||||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
message: copy.cwdStagedMessage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, onSessionRuntimeInfo, requestGateway]
|
[activeSessionId, copy, onSessionRuntimeInfo, requestGateway]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { changeSessionCwd, refreshProjectBranch }
|
return { changeSessionCwd, refreshProjectBranch }
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { type QueryClient } from '@tanstack/react-query'
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||||
@ -19,6 +20,8 @@ interface ModelControlsOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const updateModelOptionsCache = useCallback(
|
const updateModelOptionsCache = useCallback(
|
||||||
(provider: string, model: string, includeGlobal: boolean) => {
|
(provider: string, model: string, includeGlobal: boolean) => {
|
||||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||||
@ -91,12 +94,12 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
|||||||
setCurrentModel(prevModel)
|
setCurrentModel(prevModel)
|
||||||
setCurrentProvider(prevProvider)
|
setCurrentProvider(prevProvider)
|
||||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||||
notifyError(err, 'Model switch failed')
|
notifyError(err, copy.modelSwitchFailed)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
|
|||||||
import { type MutableRefObject, useCallback } from 'react'
|
import { type MutableRefObject, useCallback } from 'react'
|
||||||
|
|
||||||
import { getProfiles, transcribeAudio } from '@/hermes'
|
import { getProfiles, transcribeAudio } from '@/hermes'
|
||||||
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
import { type Translations, translateNow, useI18n } from '@/i18n'
|
||||||
|
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||||
import {
|
import {
|
||||||
attachmentDisplayText,
|
attachmentDisplayText,
|
||||||
parseCommandDispatch,
|
parseCommandDispatch,
|
||||||
@ -57,10 +58,10 @@ function blobToDataUrl(blob: Blob): Promise<string> {
|
|||||||
if (typeof reader.result === 'string') {
|
if (typeof reader.result === 'string') {
|
||||||
resolve(reader.result)
|
resolve(reader.result)
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('Could not read recorded audio'))
|
reject(new Error(translateNow('desktop.audioReadFailed')))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio')))
|
reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed'))))
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -101,12 +102,12 @@ interface SubmitTextOptions {
|
|||||||
fromQueue?: boolean
|
fromQueue?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
|
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
|
||||||
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
|
||||||
|
|
||||||
const sections = desktopCatalog.categories?.length
|
const sections = desktopCatalog.categories?.length
|
||||||
? desktopCatalog.categories
|
? desktopCatalog.categories
|
||||||
: [{ name: 'Desktop commands', pairs: desktopCatalog.pairs ?? [] }]
|
: [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }]
|
||||||
|
|
||||||
const body = sections
|
const body = sections
|
||||||
.filter(section => section.pairs.length > 0)
|
.filter(section => section.pairs.length > 0)
|
||||||
@ -118,8 +119,8 @@ function renderCommandsCatalog(catalog: CommandsCatalogLike): string {
|
|||||||
.join('\n\n')
|
.join('\n\n')
|
||||||
|
|
||||||
const tail = [
|
const tail = [
|
||||||
desktopCatalog.skill_count ? `${desktopCatalog.skill_count} skill commands available.` : '',
|
desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '',
|
||||||
desktopCatalog.warning ? `warning: ${desktopCatalog.warning}` : ''
|
desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : ''
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@ -156,6 +157,8 @@ export function usePromptActions({
|
|||||||
sttEnabled,
|
sttEnabled,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
}: PromptActionsOptions) {
|
}: PromptActionsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const appendSessionTextMessage = useCallback(
|
const appendSessionTextMessage = useCallback(
|
||||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||||
const body = text.trim()
|
const body = text.trim()
|
||||||
@ -326,7 +329,7 @@ export function usePromptActions({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
dropOptimistic(null)
|
dropOptimistic(null)
|
||||||
releaseBusy()
|
releaseBusy()
|
||||||
notifyError(err, 'Session unavailable')
|
notifyError(err, copy.sessionUnavailable)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -334,7 +337,7 @@ export function usePromptActions({
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
dropOptimistic(null)
|
dropOptimistic(null)
|
||||||
releaseBusy()
|
releaseBusy()
|
||||||
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
|
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -354,7 +357,7 @@ export function usePromptActions({
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = inlineErrorMessage(err, 'Prompt failed')
|
const message = inlineErrorMessage(err, copy.promptFailed)
|
||||||
|
|
||||||
releaseBusy()
|
releaseBusy()
|
||||||
updateSessionState(sessionId, state => ({
|
updateSessionState(sessionId, state => ({
|
||||||
@ -365,7 +368,7 @@ export function usePromptActions({
|
|||||||
id: `assistant-error-${Date.now()}`,
|
id: `assistant-error-${Date.now()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
parts: [],
|
parts: [],
|
||||||
error: message || 'Prompt failed',
|
error: message || copy.promptFailed,
|
||||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -376,12 +379,12 @@ export function usePromptActions({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (isProviderSetupError(err)) {
|
if (isProviderSetupError(err)) {
|
||||||
requestDesktopOnboarding('Add a provider credential before sending your first message.')
|
requestDesktopOnboarding(copy.providerCredentialRequired)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyError(err, 'Prompt failed')
|
notifyError(err, copy.promptFailed)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -389,6 +392,7 @@ export function usePromptActions({
|
|||||||
[
|
[
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
busyRef,
|
busyRef,
|
||||||
|
copy,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
@ -408,7 +412,7 @@ export function usePromptActions({
|
|||||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
appendSessionTextMessage(sessionId, 'system', 'empty slash command')
|
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -435,16 +439,16 @@ export function usePromptActions({
|
|||||||
|
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
setYoloActive(next)
|
setYoloActive(next)
|
||||||
notify({ kind: 'success', message: next ? 'YOLO armed for this chat' : 'YOLO off' })
|
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const active = await setSessionYolo(requestGateway, sid, next)
|
const active = await setSessionYolo(requestGateway, sid, next)
|
||||||
appendSessionTextMessage(sid, 'system', `YOLO ${active ? 'on' : 'off'} for this session`)
|
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
|
||||||
} catch {
|
} catch {
|
||||||
notify({ kind: 'error', title: 'YOLO', message: 'Could not toggle YOLO' })
|
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -467,7 +471,7 @@ export function usePromptActions({
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
message: `Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`
|
message: copy.profileStatus(current)
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -480,8 +484,8 @@ export function usePromptActions({
|
|||||||
if (!match) {
|
if (!match) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'Unknown profile',
|
title: copy.unknownProfile,
|
||||||
message: `No profile named "${target}". Available: ${profiles.map(profile => profile.name).join(', ')}`
|
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -493,9 +497,9 @@ export function usePromptActions({
|
|||||||
// Swap the live gateway now so an empty draft sends into this
|
// Swap the live gateway now so an empty draft sends into this
|
||||||
// profile immediately; an existing thread keeps its own profile.
|
// profile immediately; an existing thread keeps its own profile.
|
||||||
await ensureGatewayProfile(key)
|
await ensureGatewayProfile(key)
|
||||||
notify({ kind: 'success', message: `New chats will use profile ${match.name}.` })
|
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Failed to set profile')
|
notifyError(err, copy.setProfileFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -506,8 +510,8 @@ export function usePromptActions({
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'Session unavailable',
|
title: copy.sessionUnavailable,
|
||||||
message: 'Could not create a new session'
|
message: copy.createSessionFailed
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -570,7 +574,7 @@ export function usePromptActions({
|
|||||||
try {
|
try {
|
||||||
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
|
||||||
|
|
||||||
renderSlashOutput(renderCommandsCatalog(catalog))
|
renderSlashOutput(renderCommandsCatalog(catalog, copy))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
}
|
}
|
||||||
@ -658,6 +662,7 @@ export function usePromptActions({
|
|||||||
appendSessionTextMessage,
|
appendSessionTextMessage,
|
||||||
branchCurrentSession,
|
branchCurrentSession,
|
||||||
busyRef,
|
busyRef,
|
||||||
|
copy,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@ -687,7 +692,7 @@ export function usePromptActions({
|
|||||||
const transcribeVoiceAudio = useCallback(
|
const transcribeVoiceAudio = useCallback(
|
||||||
async (audio: Blob) => {
|
async (audio: Blob) => {
|
||||||
if (!sttEnabled) {
|
if (!sttEnabled) {
|
||||||
throw new Error('Speech-to-text is disabled in settings.')
|
throw new Error(copy.sttDisabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataUrl = await blobToDataUrl(audio)
|
const dataUrl = await blobToDataUrl(audio)
|
||||||
@ -695,7 +700,7 @@ export function usePromptActions({
|
|||||||
|
|
||||||
return result.transcript
|
return result.transcript
|
||||||
},
|
},
|
||||||
[sttEnabled]
|
[copy.sttDisabled, sttEnabled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const cancelRun = useCallback(async () => {
|
const cancelRun = useCallback(async () => {
|
||||||
@ -745,9 +750,9 @@ export function usePromptActions({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
notifyError(err, 'Stop failed')
|
notifyError(err, copy.stopFailed)
|
||||||
}
|
}
|
||||||
}, [activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState])
|
}, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
|
||||||
|
|
||||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||||
// text to the next tool result so the model reads it on its next iteration
|
// text to the next tool result so the model reads it on its next iteration
|
||||||
@ -853,10 +858,10 @@ export function usePromptActions({
|
|||||||
busy: false,
|
busy: false,
|
||||||
awaitingResponse: false
|
awaitingResponse: false
|
||||||
}))
|
}))
|
||||||
notifyError(err, 'Regenerate failed')
|
notifyError(err, copy.regenerateFailed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, requestGateway, updateSessionState]
|
[activeSessionId, copy.regenerateFailed, requestGateway, updateSessionState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const editMessage = useCallback(
|
const editMessage = useCallback(
|
||||||
@ -926,10 +931,10 @@ export function usePromptActions({
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
setAwaitingResponse(false)
|
setAwaitingResponse(false)
|
||||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||||
notifyError(surfaced, 'Edit failed')
|
notifyError(surfaced, copy.editFailed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeSessionId, activeSessionIdRef, busyRef, requestGateway, updateSessionState]
|
[activeSessionId, activeSessionIdRef, busyRef, copy.editFailed, requestGateway, updateSessionState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleThreadMessagesChange = useCallback(
|
const handleThreadMessagesChange = useCallback(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCallback, useRef } from 'react'
|
|||||||
import type { NavigateFunction } from 'react-router-dom'
|
import type { NavigateFunction } from 'react-router-dom'
|
||||||
|
|
||||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||||
@ -285,6 +286,8 @@ export function useSessionActions({
|
|||||||
syncSessionStateToView,
|
syncSessionStateToView,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
}: SessionActionsOptions) {
|
}: SessionActionsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const resumeRequestRef = useRef(0)
|
const resumeRequestRef = useRef(0)
|
||||||
|
|
||||||
const startFreshSessionDraft = useCallback(
|
const startFreshSessionDraft = useCallback(
|
||||||
@ -602,7 +605,7 @@ export function useSessionActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||||
notifyError(err, 'Resume failed')
|
notifyError(err, copy.resumeFailed)
|
||||||
} finally {
|
} finally {
|
||||||
if (isCurrentResume()) {
|
if (isCurrentResume()) {
|
||||||
busyRef.current = false
|
busyRef.current = false
|
||||||
@ -614,6 +617,7 @@ export function useSessionActions({
|
|||||||
[
|
[
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
busyRef,
|
busyRef,
|
||||||
|
copy,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
@ -630,8 +634,8 @@ export function useSessionActions({
|
|||||||
if (!sourceSessionId) {
|
if (!sourceSessionId) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Nothing to branch',
|
title: copy.nothingToBranch,
|
||||||
message: 'Start or resume a chat before branching.'
|
message: copy.branchNeedsChat
|
||||||
})
|
})
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@ -640,8 +644,8 @@ export function useSessionActions({
|
|||||||
if (busyRef.current) {
|
if (busyRef.current) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Session busy',
|
title: copy.sessionBusy,
|
||||||
message: 'Stop the current turn before branching this chat.'
|
message: copy.branchStopCurrent
|
||||||
})
|
})
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@ -671,8 +675,8 @@ export function useSessionActions({
|
|||||||
if (!branchMessages.length) {
|
if (!branchMessages.length) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Nothing to branch',
|
title: copy.nothingToBranch,
|
||||||
message: 'This message has no text to branch from.'
|
message: copy.branchNoText
|
||||||
})
|
})
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@ -686,14 +690,14 @@ export function useSessionActions({
|
|||||||
cols: 96,
|
cols: 96,
|
||||||
...(cwd && { cwd }),
|
...(cwd && { cwd }),
|
||||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||||
title: 'Branch'
|
title: copy.branchTitle
|
||||||
})
|
})
|
||||||
|
|
||||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||||
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||||
|
|
||||||
setFreshDraftReady(false)
|
setFreshDraftReady(false)
|
||||||
upsertOptimisticSession(branched, routedSessionId, 'Branch', preview)
|
upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview)
|
||||||
ensureSessionState(branched.session_id, routedSessionId)
|
ensureSessionState(branched.session_id, routedSessionId)
|
||||||
setActiveSessionId(branched.session_id)
|
setActiveSessionId(branched.session_id)
|
||||||
activeSessionIdRef.current = branched.session_id
|
activeSessionIdRef.current = branched.session_id
|
||||||
@ -723,7 +727,7 @@ export function useSessionActions({
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Branch failed')
|
notifyError(err, copy.branchFailed)
|
||||||
|
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
@ -735,6 +739,7 @@ export function useSessionActions({
|
|||||||
[
|
[
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
busyRef,
|
busyRef,
|
||||||
|
copy,
|
||||||
creatingSessionRef,
|
creatingSessionRef,
|
||||||
ensureSessionState,
|
ensureSessionState,
|
||||||
navigate,
|
navigate,
|
||||||
@ -812,12 +817,13 @@ export function useSessionActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyError(err, 'Delete failed')
|
notifyError(err, copy.deleteFailed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
|
copy,
|
||||||
navigate,
|
navigate,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionId,
|
selectedStoredSessionId,
|
||||||
@ -851,7 +857,7 @@ export function useSessionActions({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (archived) {
|
if (archived) {
|
||||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||||
@ -859,10 +865,10 @@ export function useSessionActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pinnedSessionIds.set(previousPinned)
|
$pinnedSessionIds.set(previousPinned)
|
||||||
notifyError(err, 'Archive failed')
|
notifyError(err, copy.archiveFailed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedStoredSessionId, startFreshSessionDraft]
|
[copy, selectedStoredSessionId, startFreshSessionDraft]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ function ConfigField({
|
|||||||
onChange: (value: unknown) => void
|
onChange: (value: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const c = t.settings.config
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
t.settings.fieldLabels[schemaKey] ?? FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||||
@ -88,8 +89,8 @@ function ConfigField({
|
|||||||
{option
|
{option
|
||||||
? (optionLabels?.[option] ?? prettyName(option))
|
? (optionLabels?.[option] ?? prettyName(option))
|
||||||
: schemaKey === 'display.personality'
|
: schemaKey === 'display.personality'
|
||||||
? 'None'
|
? c.none
|
||||||
: '(none)'}
|
: c.noneParen}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -109,7 +110,7 @@ function ConfigField({
|
|||||||
onChange(n)
|
onChange(n)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Not set"
|
placeholder={c.notSet}
|
||||||
type="number"
|
type="number"
|
||||||
value={value === undefined || value === null ? '' : String(value)}
|
value={value === undefined || value === null ? '' : String(value)}
|
||||||
/>
|
/>
|
||||||
@ -128,7 +129,7 @@ function ConfigField({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="comma-separated values"
|
placeholder={c.commaSeparated}
|
||||||
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
|
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -145,7 +146,7 @@ function ConfigField({
|
|||||||
/* keep last valid */
|
/* keep last valid */
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Not set"
|
placeholder={c.notSet}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
value={JSON.stringify(value, null, 2)}
|
value={JSON.stringify(value, null, 2)}
|
||||||
/>,
|
/>,
|
||||||
@ -160,14 +161,14 @@ function ConfigField({
|
|||||||
<Textarea
|
<Textarea
|
||||||
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
|
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder="Not set"
|
placeholder={c.notSet}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
className={CONTROL_TEXT}
|
className={CONTROL_TEXT}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder="Not set"
|
placeholder={c.notSet}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -186,6 +187,8 @@ export function ConfigSettings({
|
|||||||
onMainModelChanged?: (provider: string, model: string) => void
|
onMainModelChanged?: (provider: string, model: string) => void
|
||||||
importInputRef: React.RefObject<HTMLInputElement | null>
|
importInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const c = t.settings.config
|
||||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||||
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
||||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||||
@ -206,7 +209,7 @@ export function ConfigSettings({
|
|||||||
setDefaults(d)
|
setDefaults(d)
|
||||||
setSchema(s.fields)
|
setSchema(s.fields)
|
||||||
})
|
})
|
||||||
.catch(err => notifyError(err, 'Settings failed to load'))
|
.catch(err => notifyError(err, c.failedLoad))
|
||||||
|
|
||||||
return () => void (cancelled = true)
|
return () => void (cancelled = true)
|
||||||
}, [])
|
}, [])
|
||||||
@ -250,7 +253,7 @@ export function ConfigSettings({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (saveVersionRef.current === v) {
|
if (saveVersionRef.current === v) {
|
||||||
notifyError(err, 'Autosave failed')
|
notifyError(err, c.autosaveFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@ -323,9 +326,9 @@ export function ConfigSettings({
|
|||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
try {
|
try {
|
||||||
updateConfig(JSON.parse(String(reader.result)))
|
updateConfig(JSON.parse(String(reader.result)))
|
||||||
notify({ kind: 'success', title: 'Config imported', message: 'Saving…' })
|
notify({ kind: 'success', title: c.imported, message: t.common.saving })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Invalid config JSON')
|
notifyError(err, c.invalidJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,7 +337,7 @@ export function ConfigSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!config || !schema) {
|
if (!config || !schema) {
|
||||||
return <LoadingState label="Loading Hermes configuration..." />
|
return <LoadingState label={c.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -345,7 +348,7 @@ export function ConfigSettings({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<EmptyState description="This section has no adjustable settings." title="Nothing to configure" />
|
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{fields.map(([key, field]) => (
|
{fields.map(([key, field]) => (
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
import type { ThemeMode } from '@/themes/context'
|
import type { ThemeMode } from '@/themes/context'
|
||||||
|
|
||||||
import type { DesktopConfigSection } from './types'
|
import type { DesktopConfigSection } from './types'
|
||||||
|
import { defineFieldCopy } from './field-copy'
|
||||||
|
|
||||||
// Provider group definitions used to fold raw env-var names like
|
// Provider group definitions used to fold raw env-var names like
|
||||||
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
|
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
|
||||||
@ -245,103 +246,175 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
|||||||
'updates.non_interactive_local_changes': ['stash', 'discard']
|
'updates.non_interactive_local_changes': ['stash', 'discard']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FIELD_LABELS: Record<string, string> = {
|
export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
|
||||||
model: 'Default Model',
|
model: 'Default Model',
|
||||||
model_context_length: 'Context Window',
|
model_context_length: 'Context Window',
|
||||||
fallback_providers: 'Fallback Models',
|
fallback_providers: 'Fallback Models',
|
||||||
toolsets: 'Enabled Toolsets',
|
toolsets: 'Enabled Toolsets',
|
||||||
timezone: 'Timezone',
|
timezone: 'Timezone',
|
||||||
'display.personality': 'Personality',
|
display: {
|
||||||
'display.show_reasoning': 'Reasoning Blocks',
|
personality: 'Personality',
|
||||||
'agent.max_turns': 'Max Agent Steps',
|
show_reasoning: 'Reasoning Blocks'
|
||||||
'agent.image_input_mode': 'Image Attachments',
|
},
|
||||||
'terminal.cwd': 'Working Directory',
|
agent: {
|
||||||
'terminal.backend': 'Execution Backend',
|
max_turns: 'Max Agent Steps',
|
||||||
'terminal.timeout': 'Command Timeout',
|
image_input_mode: 'Image Attachments',
|
||||||
'terminal.persistent_shell': 'Persistent Shell',
|
api_max_retries: 'API Retries',
|
||||||
'terminal.env_passthrough': 'Environment Passthrough',
|
service_tier: 'Service Tier',
|
||||||
|
tool_use_enforcement: 'Tool-Use Enforcement'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
cwd: 'Working Directory',
|
||||||
|
backend: 'Execution Backend',
|
||||||
|
timeout: 'Command Timeout',
|
||||||
|
persistent_shell: 'Persistent Shell',
|
||||||
|
env_passthrough: 'Environment Passthrough'
|
||||||
|
},
|
||||||
file_read_max_chars: 'File Read Limit',
|
file_read_max_chars: 'File Read Limit',
|
||||||
'tool_output.max_bytes': 'Terminal Output Limit',
|
tool_output: {
|
||||||
'tool_output.max_lines': 'File Page Limit',
|
max_bytes: 'Terminal Output Limit',
|
||||||
'tool_output.max_line_length': 'Line Length Limit',
|
max_lines: 'File Page Limit',
|
||||||
'code_execution.mode': 'Code Execution Mode',
|
max_line_length: 'Line Length Limit'
|
||||||
'approvals.mode': 'Approval Mode',
|
},
|
||||||
'approvals.timeout': 'Approval Timeout',
|
code_execution: {
|
||||||
'approvals.mcp_reload_confirm': 'Confirm MCP Reloads',
|
mode: 'Code Execution Mode'
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
mode: 'Approval Mode',
|
||||||
|
timeout: 'Approval Timeout',
|
||||||
|
mcp_reload_confirm: 'Confirm MCP Reloads'
|
||||||
|
},
|
||||||
command_allowlist: 'Command Allowlist',
|
command_allowlist: 'Command Allowlist',
|
||||||
'security.redact_secrets': 'Redact Secrets',
|
security: {
|
||||||
'security.allow_private_urls': 'Allow Private URLs',
|
redact_secrets: 'Redact Secrets',
|
||||||
'browser.allow_private_urls': 'Browser Private URLs',
|
allow_private_urls: 'Allow Private URLs'
|
||||||
'browser.auto_local_for_private_urls': 'Local Browser For Private URLs',
|
},
|
||||||
'checkpoints.enabled': 'File Checkpoints',
|
browser: {
|
||||||
'checkpoints.max_snapshots': 'Checkpoint Limit',
|
allow_private_urls: 'Browser Private URLs',
|
||||||
'voice.record_key': 'Voice Shortcut',
|
auto_local_for_private_urls: 'Local Browser For Private URLs'
|
||||||
'voice.max_recording_seconds': 'Max Recording Length',
|
},
|
||||||
'voice.auto_tts': 'Read Responses Aloud',
|
checkpoints: {
|
||||||
'stt.enabled': 'Speech To Text',
|
enabled: 'File Checkpoints',
|
||||||
'stt.provider': 'Speech-To-Text Provider',
|
max_snapshots: 'Checkpoint Limit'
|
||||||
'stt.local.model': 'Local Transcription Model',
|
},
|
||||||
'stt.local.language': 'Transcription Language',
|
voice: {
|
||||||
'stt.elevenlabs.model_id': 'ElevenLabs STT Model',
|
record_key: 'Voice Shortcut',
|
||||||
'stt.elevenlabs.language_code': 'ElevenLabs Language',
|
max_recording_seconds: 'Max Recording Length',
|
||||||
'stt.elevenlabs.tag_audio_events': 'Tag Audio Events',
|
auto_tts: 'Read Responses Aloud'
|
||||||
'stt.elevenlabs.diarize': 'Speaker Diarization',
|
},
|
||||||
'tts.provider': 'Text-To-Speech Provider',
|
stt: {
|
||||||
'tts.edge.voice': 'Edge Voice',
|
enabled: 'Speech To Text',
|
||||||
'tts.openai.model': 'OpenAI TTS Model',
|
provider: 'Speech-To-Text Provider',
|
||||||
'tts.openai.voice': 'OpenAI Voice',
|
local: {
|
||||||
'tts.elevenlabs.voice_id': 'ElevenLabs Voice',
|
model: 'Local Transcription Model',
|
||||||
'tts.elevenlabs.model_id': 'ElevenLabs Model',
|
language: 'Transcription Language'
|
||||||
'memory.memory_enabled': 'Persistent Memory',
|
},
|
||||||
'memory.user_profile_enabled': 'User Profile',
|
elevenlabs: {
|
||||||
'memory.memory_char_limit': 'Memory Budget',
|
model_id: 'ElevenLabs STT Model',
|
||||||
'memory.user_char_limit': 'Profile Budget',
|
language_code: 'ElevenLabs Language',
|
||||||
'memory.provider': 'Memory Provider',
|
tag_audio_events: 'Tag Audio Events',
|
||||||
'context.engine': 'Context Engine',
|
diarize: 'Speaker Diarization'
|
||||||
'compression.enabled': 'Auto-Compression',
|
}
|
||||||
'compression.threshold': 'Compression Threshold',
|
},
|
||||||
'compression.target_ratio': 'Compression Target',
|
tts: {
|
||||||
'compression.protect_last_n': 'Protected Recent Messages',
|
provider: 'Text-To-Speech Provider',
|
||||||
'agent.api_max_retries': 'API Retries',
|
edge: {
|
||||||
'agent.service_tier': 'Service Tier',
|
voice: 'Edge Voice'
|
||||||
'agent.tool_use_enforcement': 'Tool-Use Enforcement',
|
},
|
||||||
'delegation.model': 'Subagent Model',
|
openai: {
|
||||||
'delegation.provider': 'Subagent Provider',
|
model: 'OpenAI TTS Model',
|
||||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
voice: 'OpenAI Voice'
|
||||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
},
|
||||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
elevenlabs: {
|
||||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
voice_id: 'ElevenLabs Voice',
|
||||||
'updates.non_interactive_local_changes': 'In-App Update Local Changes'
|
model_id: 'ElevenLabs Model'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
memory_enabled: 'Persistent Memory',
|
||||||
|
user_profile_enabled: 'User Profile',
|
||||||
|
memory_char_limit: 'Memory Budget',
|
||||||
|
user_char_limit: 'Profile Budget',
|
||||||
|
provider: 'Memory Provider'
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
engine: 'Context Engine'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: 'Auto-Compression',
|
||||||
|
threshold: 'Compression Threshold',
|
||||||
|
target_ratio: 'Compression Target',
|
||||||
|
protect_last_n: 'Protected Recent Messages'
|
||||||
|
},
|
||||||
|
delegation: {
|
||||||
|
model: 'Subagent Model',
|
||||||
|
provider: 'Subagent Provider',
|
||||||
|
max_iterations: 'Subagent Turn Limit',
|
||||||
|
max_concurrent_children: 'Parallel Subagents',
|
||||||
|
child_timeout_seconds: 'Subagent Timeout',
|
||||||
|
reasoning_effort: 'Subagent Reasoning Effort'
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes: 'In-App Update Local Changes'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
|
||||||
model: 'Used for new chats unless you pick a different model in the composer.',
|
model: 'Used for new chats unless you pick a different model in the composer.',
|
||||||
model_context_length: "Leave at 0 to use the selected model's detected context window.",
|
model_context_length: "Leave at 0 to use the selected model's detected context window.",
|
||||||
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
|
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
|
||||||
'display.personality': 'Default assistant style for new sessions.',
|
display: {
|
||||||
|
personality: 'Default assistant style for new sessions.',
|
||||||
|
show_reasoning: 'Show reasoning sections when the backend provides them.'
|
||||||
|
},
|
||||||
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
|
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
|
||||||
'display.show_reasoning': 'Show reasoning sections when the backend provides them.',
|
agent: {
|
||||||
'agent.image_input_mode': 'Controls how image attachments are sent to the model.',
|
image_input_mode: 'Controls how image attachments are sent to the model.',
|
||||||
'terminal.cwd': 'Default project folder for tool and terminal work.',
|
max_turns: 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||||
'code_execution.mode': 'How strictly code execution is scoped to the current project.',
|
},
|
||||||
'terminal.persistent_shell': 'Keep shell state between commands when the backend supports it.',
|
terminal: {
|
||||||
'terminal.env_passthrough': 'Environment variables to pass into tool execution.',
|
cwd: 'Default project folder for tool and terminal work.',
|
||||||
|
persistent_shell: 'Keep shell state between commands when the backend supports it.',
|
||||||
|
env_passthrough: 'Environment variables to pass into tool execution.'
|
||||||
|
},
|
||||||
|
code_execution: {
|
||||||
|
mode: 'How strictly code execution is scoped to the current project.'
|
||||||
|
},
|
||||||
file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
|
file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
|
||||||
'approvals.mode': 'How Hermes handles commands that need explicit approval.',
|
approvals: {
|
||||||
'approvals.timeout': 'How long approval prompts wait before timing out.',
|
mode: 'How Hermes handles commands that need explicit approval.',
|
||||||
'security.redact_secrets': 'Hide detected secrets from model-visible content when possible.',
|
timeout: 'How long approval prompts wait before timing out.'
|
||||||
'checkpoints.enabled': 'Create rollback snapshots before file edits.',
|
},
|
||||||
'memory.memory_enabled': 'Save durable memories that can help future sessions.',
|
security: {
|
||||||
'memory.user_profile_enabled': 'Maintain a compact profile of user preferences.',
|
redact_secrets: 'Hide detected secrets from model-visible content when possible.'
|
||||||
'context.engine': 'Strategy for managing long conversations near the context limit.',
|
},
|
||||||
'compression.enabled': 'Summarize older context when conversations get large.',
|
checkpoints: {
|
||||||
'voice.auto_tts': 'Automatically speak assistant responses.',
|
enabled: 'Create rollback snapshots before file edits.'
|
||||||
'stt.enabled': 'Enable local or provider-backed speech transcription.',
|
},
|
||||||
'stt.elevenlabs.language_code': 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.',
|
memory: {
|
||||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.',
|
memory_enabled: 'Save durable memories that can help future sessions.',
|
||||||
'updates.non_interactive_local_changes':
|
user_profile_enabled: 'Maintain a compact profile of user preferences.'
|
||||||
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
|
},
|
||||||
}
|
context: {
|
||||||
|
engine: 'Strategy for managing long conversations near the context limit.'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: 'Summarize older context when conversations get large.'
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
auto_tts: 'Automatically speak assistant responses.'
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
enabled: 'Enable local or provider-backed speech transcription.',
|
||||||
|
elevenlabs: {
|
||||||
|
language_code: 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes:
|
||||||
|
'When Hermes updates itself from the app (no terminal prompt), keep local source edits (stash) or throw them away (discard). Terminal updates always ask.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Curated desktop config surface: only fields a user might tune from the app.
|
// Curated desktop config surface: only fields a user might tune from the app.
|
||||||
export const SECTIONS: DesktopConfigSection[] = [
|
export const SECTIONS: DesktopConfigSection[] = [
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { type ChangeEvent, type KeyboardEvent } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { translateNow, useI18n } from '@/i18n'
|
||||||
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
|
import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { EnvVarInfo } from '@/types/hermes'
|
import type { EnvVarInfo } from '@/types/hermes'
|
||||||
@ -27,7 +28,11 @@ export const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
|||||||
.replace(/\b\w/g, c => c.toUpperCase())
|
.replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
|
||||||
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
|
export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string =>
|
||||||
isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional'
|
isKeyVar(key, info)
|
||||||
|
? translateNow('settings.credentials.pasteLabelKey', label)
|
||||||
|
: /URL$/i.test(key)
|
||||||
|
? 'https://…'
|
||||||
|
: translateNow('settings.credentials.optional')
|
||||||
|
|
||||||
// A single credential field: a set key shows as a filled read-only input
|
// A single credential field: a set key shows as a filled read-only input
|
||||||
// (redacted value) that edits in place on click. Save appears once typed; a set
|
// (redacted value) that edits in place on click. Save appears once typed; a set
|
||||||
@ -43,6 +48,7 @@ export function KeyField({
|
|||||||
rowProps: KeyRowProps
|
rowProps: KeyRowProps
|
||||||
varKey: string
|
varKey: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
||||||
const editing = edits[varKey] !== undefined
|
const editing = edits[varKey] !== undefined
|
||||||
const draft = edits[varKey] ?? ''
|
const draft = edits[varKey] ?? ''
|
||||||
@ -84,14 +90,14 @@ export function KeyField({
|
|||||||
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
|
className={cn(CREDENTIAL_CONTROL_CLASS, 'min-w-0 flex-1')}
|
||||||
onChange={update}
|
onChange={update}
|
||||||
onKeyDown={keydown}
|
onKeyDown={keydown}
|
||||||
placeholder={placeholder ?? 'Paste key'}
|
placeholder={placeholder ?? t.settings.credentials.pasteKey}
|
||||||
type={editType}
|
type={editType}
|
||||||
value={draft}
|
value={draft}
|
||||||
/>
|
/>
|
||||||
{dirty && (
|
{dirty && (
|
||||||
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||||
{busy ? 'Saving' : 'Save'}
|
{busy ? t.settings.credentials.saving : t.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -106,12 +112,12 @@ export function KeyField({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Remove
|
{t.settings.credentials.remove}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-muted-foreground">or</span>
|
<span className="text-muted-foreground">{t.settings.credentials.or}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-muted-foreground">esc to cancel</span>
|
<span className="text-muted-foreground">{t.settings.credentials.escToCancel}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -119,6 +125,8 @@ export function KeyField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CredentialDocsLink({ href }: { href: string }) {
|
function CredentialDocsLink({ href }: { href: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||||
@ -127,7 +135,7 @@ function CredentialDocsLink({ href }: { href: string }) {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
Get a key
|
{t.settings.credentials.getKey}
|
||||||
<ExternalLink className="size-3" />
|
<ExternalLink className="size-3" />
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
@ -223,6 +231,7 @@ export function CredentialKeyCard({
|
|||||||
|
|
||||||
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
|
/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */
|
||||||
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
|
export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const docsUrl = group.docsUrl?.trim()
|
const docsUrl = group.docsUrl?.trim()
|
||||||
const description = group.description?.trim()
|
const description = group.description?.trim()
|
||||||
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
|
const expandable = Boolean(description || docsUrl || group.advanced.length > 0)
|
||||||
@ -283,7 +292,7 @@ export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps
|
|||||||
>
|
>
|
||||||
<KeyField
|
<KeyField
|
||||||
info={group.primary[1]}
|
info={group.primary[1]}
|
||||||
placeholder={`Paste ${group.name} key`}
|
placeholder={t.settings.credentials.pasteLabelKey(group.name)}
|
||||||
rowProps={rowProps}
|
rowProps={rowProps}
|
||||||
varKey={group.primary[0]}
|
varKey={group.primary[0]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { type IconComponent } from '@/lib/icons'
|
import { type IconComponent } from '@/lib/icons'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
import type { EnvVarInfo } from '@/types/hermes'
|
import type { EnvVarInfo } from '@/types/hermes'
|
||||||
@ -41,6 +42,9 @@ export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHe
|
|||||||
// credential pages (Providers, Keys) share one source of truth and one set of
|
// credential pages (Providers, Keys) share one source of truth and one set of
|
||||||
// mutation handlers instead of duplicating the plumbing.
|
// mutation handlers instead of duplicating the plumbing.
|
||||||
export function useEnvCredentials(): UseEnvCredentials {
|
export function useEnvCredentials(): UseEnvCredentials {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const credentials = t.settings.credentials
|
||||||
|
const toolsets = t.settings.toolsets
|
||||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||||
@ -67,7 +71,7 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
setVars(next)
|
setVars(next)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'API keys failed to load')
|
notifyError(err, t.settings.keys.failedLoad)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -96,9 +100,9 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
await setEnvVar(key, value)
|
await setEnvVar(key, value)
|
||||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||||
clearLocalState(key)
|
clearLocalState(key)
|
||||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
notify({ kind: 'success', title: toolsets.savedTitle, message: toolsets.savedMessage(key) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to save ${key}`)
|
notifyError(err, toolsets.failedSave(key))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
@ -111,7 +115,7 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return { message: 'Enter a value first.', ok: false }
|
return { message: credentials.enterValueFirst, ok: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(key)
|
setSaving(key)
|
||||||
@ -120,20 +124,20 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
await setEnvVar(key, trimmed)
|
await setEnvVar(key, trimmed)
|
||||||
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
|
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
|
||||||
clearLocalState(key)
|
clearLocalState(key)
|
||||||
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
|
notify({ kind: 'success', message: toolsets.savedMessage(key), title: toolsets.savedTitle })
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to save ${key}`)
|
notifyError(err, toolsets.failedSave(key))
|
||||||
|
|
||||||
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
|
return { message: err instanceof Error ? err.message : credentials.couldNotSave, ok: false }
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClear(key: string) {
|
async function handleClear(key: string) {
|
||||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
if (!window.confirm(toolsets.removeConfirm(key))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,9 +147,9 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
await deleteEnvVar(key)
|
await deleteEnvVar(key)
|
||||||
patchVar(key, { is_set: false, redacted_value: null })
|
patchVar(key, { is_set: false, redacted_value: null })
|
||||||
clearLocalState(key)
|
clearLocalState(key)
|
||||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
notify({ kind: 'success', title: toolsets.removedTitle, message: toolsets.removedMessage(key) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to remove ${key}`)
|
notifyError(err, toolsets.failedRemove(key))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(null)
|
setSaving(null)
|
||||||
}
|
}
|
||||||
@ -162,7 +166,7 @@ export function useEnvCredentials(): UseEnvCredentials {
|
|||||||
const result = await revealEnvVar(key)
|
const result = await revealEnvVar(key)
|
||||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to reveal ${key}`)
|
notifyError(err, toolsets.failedReveal(key))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
|
import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -41,6 +42,8 @@ export function EnvVarActionsMenu({
|
|||||||
showReveal = true,
|
showReveal = true,
|
||||||
sideOffset = 6
|
sideOffset = 6
|
||||||
}: EnvVarActionsMenuProps) {
|
}: EnvVarActionsMenuProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.envActions
|
||||||
const hasClear = isSet && onClear
|
const hasClear = isSet && onClear
|
||||||
const hasReveal = isSet && showReveal && onReveal
|
const hasReveal = isSet && showReveal && onReveal
|
||||||
const hasDocs = Boolean(docsUrl?.trim())
|
const hasDocs = Boolean(docsUrl?.trim())
|
||||||
@ -50,7 +53,7 @@ export function EnvVarActionsMenu({
|
|||||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align={align}
|
align={align}
|
||||||
aria-label={`Actions for ${label}`}
|
aria-label={copy.actionsFor(label)}
|
||||||
className="w-44"
|
className="w-44"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
>
|
>
|
||||||
@ -63,7 +66,7 @@ export function EnvVarActionsMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
<span>Docs</span>
|
<span>{copy.docs}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -75,7 +78,7 @@ export function EnvVarActionsMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
{isRevealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||||
<span>{isRevealed ? 'Hide value' : 'Reveal value'}</span>
|
<span>{isRevealed ? copy.hideValue : copy.revealValue}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -86,7 +89,7 @@ export function EnvVarActionsMenu({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Codicon name="edit" size="0.875rem" />
|
<Codicon name="edit" size="0.875rem" />
|
||||||
<span>{isSet ? 'Replace' : 'Set'}</span>
|
<span>{isSet ? copy.replace : copy.set}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{hasClear && (
|
{hasClear && (
|
||||||
@ -101,7 +104,7 @@ export function EnvVarActionsMenu({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
<span>Clear</span>
|
<span>{copy.clear}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -115,12 +118,15 @@ interface EnvVarActionsTriggerProps extends Omit<React.ComponentProps<typeof But
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
|
export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.envActions
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
aria-label={`Actions for ${label}`}
|
aria-label={copy.actionsFor(label)}
|
||||||
className={cn('text-muted-foreground hover:text-foreground', className)}
|
className={cn('text-muted-foreground hover:text-foreground', className)}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
title="Credential actions"
|
title={copy.credentialActions}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
44
apps/desktop/src/app/settings/field-copy.ts
Normal file
44
apps/desktop/src/app/settings/field-copy.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export interface FieldCopyTree {
|
||||||
|
[key: string]: string | FieldCopyTree
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldCopyTree(value: unknown): value is FieldCopyTree {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineFieldCopy(copy: FieldCopyTree): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
|
||||||
|
const visit = (node: FieldCopyTree, prefix: string[] = []) => {
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
const parts = key.split('.')
|
||||||
|
|
||||||
|
if (parts.some(part => part.length === 0)) {
|
||||||
|
throw new Error(`Invalid field copy key: ${[...prefix, key].join('.')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = [...prefix, ...parts]
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const flatKey = path.join('.')
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, flatKey)) {
|
||||||
|
throw new Error(`Duplicate field copy key: ${flatKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
result[flatKey] = value
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFieldCopyTree(value)) {
|
||||||
|
throw new Error(`Invalid field copy value for key: ${path.join('.')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(value, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(copy)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@ -94,6 +95,8 @@ function ScopeChip({ active, label, onSelect }: { active: boolean; label: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GatewaySettings() {
|
export function GatewaySettings() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const g = t.settings.gateway
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
@ -144,7 +147,7 @@ export function GatewaySettings() {
|
|||||||
|
|
||||||
setState(config)
|
setState(config)
|
||||||
})
|
})
|
||||||
.catch(err => notifyError(err, 'Gateway settings failed to load'))
|
.catch(err => notifyError(err, g.failedLoad))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -242,8 +245,8 @@ export function GatewaySettings() {
|
|||||||
return providers.map(p => p.displayName || p.name).join(' / ')
|
return providers.map(p => p.displayName || p.name).join(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'your identity provider'
|
return t.boot.failure.identityProvider
|
||||||
}, [probe])
|
}, [probe, t.boot.failure.identityProvider])
|
||||||
|
|
||||||
// A username/password gateway authenticates through a credential form on the
|
// A username/password gateway authenticates through a credential form on the
|
||||||
// gateway's /login page (POST /auth/password-login) rather than an OAuth
|
// gateway's /login page (POST /auth/password-login) rather than an OAuth
|
||||||
@ -288,11 +291,11 @@ export function GatewaySettings() {
|
|||||||
if (state.mode === 'remote' && !canUseRemote) {
|
if (state.mode === 'remote' && !canUseRemote) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Remote gateway incomplete',
|
title: g.incompleteTitle,
|
||||||
message:
|
message:
|
||||||
authMode === 'oauth'
|
authMode === 'oauth'
|
||||||
? 'Enter a remote URL and sign in before switching to remote.'
|
? g.incompleteSignIn
|
||||||
: 'Enter a remote URL and session token before switching to remote.'
|
: g.incompleteToken
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -309,11 +312,11 @@ export function GatewaySettings() {
|
|||||||
setRemoteToken('')
|
setRemoteToken('')
|
||||||
notify({
|
notify({
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
title: apply ? 'Gateway connection restarting' : 'Gateway settings saved',
|
title: apply ? g.restartingTitle : g.savedTitle,
|
||||||
message: apply ? 'Hermes Desktop will reconnect using the saved settings.' : 'Saved for the next restart.'
|
message: apply ? g.restartingMessage : g.savedMessage
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, apply ? 'Could not apply gateway settings' : 'Could not save gateway settings')
|
notifyError(err, apply ? g.applyFailed : g.saveFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -324,7 +327,7 @@ export function GatewaySettings() {
|
|||||||
// refresh the connection status from the saved config once it completes.
|
// refresh the connection status from the saved config once it completes.
|
||||||
const signIn = async () => {
|
const signIn = async () => {
|
||||||
if (!trimmedUrl) {
|
if (!trimmedUrl) {
|
||||||
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
|
notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -348,16 +351,16 @@ export function GatewaySettings() {
|
|||||||
if (result.connected) {
|
if (result.connected) {
|
||||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||||
setState(refreshed)
|
setState(refreshed)
|
||||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
|
||||||
} else {
|
} else {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Sign-in incomplete',
|
title: t.boot.failure.signInIncompleteTitle,
|
||||||
message: 'The login window closed before authentication finished.'
|
message: t.boot.failure.signInIncompleteMessage
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Sign-in failed')
|
notifyError(err, g.signInFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSigningIn(false)
|
setSigningIn(false)
|
||||||
}
|
}
|
||||||
@ -370,9 +373,9 @@ export function GatewaySettings() {
|
|||||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||||
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
const refreshed = await window.hermesDesktop.getConnectionConfig(scope)
|
||||||
setState(refreshed)
|
setState(refreshed)
|
||||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Sign-out failed')
|
notifyError(err, g.signOutFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSigningIn(false)
|
setSigningIn(false)
|
||||||
}
|
}
|
||||||
@ -382,11 +385,11 @@ export function GatewaySettings() {
|
|||||||
if (!canUseRemote) {
|
if (!canUseRemote) {
|
||||||
notify({
|
notify({
|
||||||
kind: 'warning',
|
kind: 'warning',
|
||||||
title: 'Remote gateway incomplete',
|
title: g.incompleteTitle,
|
||||||
message:
|
message:
|
||||||
authMode === 'oauth'
|
authMode === 'oauth'
|
||||||
? 'Enter a remote URL and sign in before testing.'
|
? g.incompleteSignInTest
|
||||||
: 'Enter a remote URL and session token before testing.'
|
: g.incompleteTokenTest
|
||||||
})
|
})
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -404,25 +407,25 @@ export function GatewaySettings() {
|
|||||||
remoteUrl: trimmedUrl
|
remoteUrl: trimmedUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
|
const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
|
||||||
setLastTest(message)
|
setLastTest(message)
|
||||||
notify({ kind: 'success', title: 'Remote gateway reachable', message })
|
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Remote gateway test failed')
|
notifyError(err, g.testFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false)
|
setTesting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState label="Loading gateway settings..." />
|
return <LoadingState label={g.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
description="The desktop IPC bridge does not expose gateway settings."
|
description={g.unavailableDesc}
|
||||||
title="Gateway settings unavailable"
|
title={g.unavailableTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -432,23 +435,21 @@ export function GatewaySettings() {
|
|||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
<div className="flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||||
<Globe className="size-4 text-muted-foreground" />
|
<Globe className="size-4 text-muted-foreground" />
|
||||||
Gateway Connection
|
{g.title}
|
||||||
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
|
{state.envOverride ? <Pill tone="primary">{g.envOverride}</Pill> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
<p className="mt-2 max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||||
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
|
{g.intro}
|
||||||
an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it
|
|
||||||
its own remote host.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{namedProfiles.length > 0 ? (
|
{namedProfiles.length > 0 ? (
|
||||||
<div className="mb-5 grid gap-2">
|
<div className="mb-5 grid gap-2">
|
||||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||||
Applies to
|
{g.appliesTo}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<ScopeChip active={scope === null} label="All profiles" onSelect={() => setScope(null)} />
|
<ScopeChip active={scope === null} label={g.allProfiles} onSelect={() => setScope(null)} />
|
||||||
{namedProfiles.map(profile => (
|
{namedProfiles.map(profile => (
|
||||||
<ScopeChip
|
<ScopeChip
|
||||||
active={scope === profile.name}
|
active={scope === profile.name}
|
||||||
@ -459,9 +460,7 @@ export function GatewaySettings() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
<p className="text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||||
{scope === null
|
{scope === null ? g.defaultConnection : g.profileConnection(scope)}
|
||||||
? 'Default connection for every profile that has no override of its own.'
|
|
||||||
: `Connection used only when “${scope}” is the active profile. Set it to Local to inherit the default.`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -470,10 +469,9 @@ export function GatewaySettings() {
|
|||||||
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
<div className="mb-5 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Environment variables are controlling this desktop session.</div>
|
<div className="font-medium">{g.envOverrideTitle}</div>
|
||||||
<div className="mt-1 leading-5">
|
<div className="mt-1 leading-5">
|
||||||
Unset <code>HERMES_DESKTOP_REMOTE_URL</code> and <code>HERMES_DESKTOP_REMOTE_TOKEN</code> to use the saved
|
{g.envOverrideDesc}
|
||||||
setting below.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -482,19 +480,19 @@ export function GatewaySettings() {
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<ModeCard
|
<ModeCard
|
||||||
active={state.mode === 'local'}
|
active={state.mode === 'local'}
|
||||||
description="Start a private Hermes backend on localhost. This is the default and works offline."
|
description={g.localDesc}
|
||||||
disabled={state.envOverride}
|
disabled={state.envOverride}
|
||||||
icon={Monitor}
|
icon={Monitor}
|
||||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||||
title="Local gateway"
|
title={g.localTitle}
|
||||||
/>
|
/>
|
||||||
<ModeCard
|
<ModeCard
|
||||||
active={state.mode === 'remote'}
|
active={state.mode === 'remote'}
|
||||||
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
|
description={g.remoteDesc}
|
||||||
disabled={state.envOverride}
|
disabled={state.envOverride}
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||||
title="Remote gateway"
|
title={g.remoteTitle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -509,21 +507,21 @@ export function GatewaySettings() {
|
|||||||
value={state.remoteUrl}
|
value={state.remoteUrl}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
|
description={g.remoteUrlDesc}
|
||||||
title="Remote URL"
|
title={g.remoteUrlTitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Checking how this gateway authenticates…
|
{g.probing}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.
|
{g.probeError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -534,30 +532,30 @@ export function GatewaySettings() {
|
|||||||
oauthConnected ? (
|
oauthConnected ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pill tone="primary">
|
<Pill tone="primary">
|
||||||
<Check className="size-3" /> Signed in
|
<Check className="size-3" /> {g.signedIn}
|
||||||
</Pill>
|
</Pill>
|
||||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
Sign out
|
{g.signOut}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||||
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
|
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
oauthConnected
|
oauthConnected
|
||||||
? isPasswordProvider
|
? isPasswordProvider
|
||||||
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
|
? g.authSignedInPassword
|
||||||
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
|
: g.authSignedInOauth
|
||||||
: isPasswordProvider
|
: isPasswordProvider
|
||||||
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
|
? g.authNeedsPassword
|
||||||
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
|
: g.authNeedsOauth(providerLabel)
|
||||||
}
|
}
|
||||||
title="Authentication"
|
title={g.authTitle}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -571,14 +569,14 @@ export function GatewaySettings() {
|
|||||||
disabled={state.envOverride}
|
disabled={state.envOverride}
|
||||||
onChange={event => setRemoteToken(event.target.value)}
|
onChange={event => setRemoteToken(event.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken
|
||||||
}
|
}
|
||||||
type="password"
|
type="password"
|
||||||
value={remoteToken}
|
value={remoteToken}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
description={g.tokenDesc}
|
||||||
title="Session token"
|
title={g.tokenTitle}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -594,14 +592,14 @@ export function GatewaySettings() {
|
|||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
Test remote
|
{g.testRemote}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||||
Save for next restart
|
{g.saveForRestart}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||||
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
Save and reconnect
|
{g.saveAndReconnect}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -610,11 +608,11 @@ export function GatewaySettings() {
|
|||||||
action={
|
action={
|
||||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
||||||
<FileText className="size-4" />
|
<FileText className="size-4" />
|
||||||
Open logs
|
{g.openLogs}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
description="Reveal desktop.log in your file manager — useful when the gateway fails to start."
|
description={g.diagnosticsDesc}
|
||||||
title="Diagnostics"
|
title={g.diagnostics}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsContent>
|
</SettingsContent>
|
||||||
|
|||||||
@ -2,9 +2,53 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import type { HermesConfigRecord } from '@/types/hermes'
|
import type { HermesConfigRecord } from '@/types/hermes'
|
||||||
|
|
||||||
|
import { defineFieldCopy } from './field-copy'
|
||||||
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
|
||||||
|
|
||||||
describe('settings helpers', () => {
|
describe('settings helpers', () => {
|
||||||
|
describe('defineFieldCopy', () => {
|
||||||
|
it('flattens nested field copy paths', () => {
|
||||||
|
const copy = defineFieldCopy({
|
||||||
|
display: {
|
||||||
|
personality: 'Personality'
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
elevenlabs: {
|
||||||
|
language_code: 'Language'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(copy[['display', 'personality'].join('.')]).toBe('Personality')
|
||||||
|
expect(copy[['stt', 'elevenlabs', 'language_code'].join('.')]).toBe('Language')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps top-level flat field keys', () => {
|
||||||
|
expect(
|
||||||
|
defineFieldCopy({
|
||||||
|
model_context_length: 'Context Window',
|
||||||
|
file_read_max_chars: 'File Read Limit'
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
model_context_length: 'Context Window',
|
||||||
|
file_read_max_chars: 'File Read Limit'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate flattened paths', () => {
|
||||||
|
const duplicateKey = ['display', 'personality'].join('.')
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
defineFieldCopy({
|
||||||
|
display: {
|
||||||
|
personality: 'Personality'
|
||||||
|
},
|
||||||
|
[duplicateKey]: 'Duplicate'
|
||||||
|
})
|
||||||
|
).toThrow('Duplicate field copy key: display.personality')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('reads and writes nested config paths', () => {
|
it('reads and writes nested config paths', () => {
|
||||||
const config: HermesConfigRecord = { display: { theme: 'mono' } }
|
const config: HermesConfigRecord = { display: { theme: 'mono' } }
|
||||||
const next = setNested(config, 'display.theme', 'slate')
|
const next = setNested(config, 'display.theme', 'slate')
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import type { EnvVarInfo } from '@/types/hermes'
|
import type { EnvVarInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui'
|
||||||
@ -27,6 +28,7 @@ const VIEW_CATEGORIES: Record<KeysView, readonly string[]> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KeysSettings({ view }: KeysSettingsProps) {
|
export function KeysSettings({ view }: KeysSettingsProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { rowProps, vars } = useEnvCredentials()
|
const { rowProps, vars } = useEnvCredentials()
|
||||||
const [openKey, setOpenKey] = useState<null | string>(null)
|
const [openKey, setOpenKey] = useState<null | string>(null)
|
||||||
|
|
||||||
@ -51,7 +53,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
|||||||
}, [vars])
|
}, [vars])
|
||||||
|
|
||||||
if (!vars) {
|
if (!vars) {
|
||||||
return <LoadingState label="Loading API keys and credentials..." />
|
return <LoadingState label={t.settings.keys.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = groups.filter(g => g.category === view)
|
const visible = groups.filter(g => g.category === view)
|
||||||
@ -82,7 +84,7 @@ export function KeysSettings({ view }: KeysSettingsProps) {
|
|||||||
|
|
||||||
{visible.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||||
Nothing configured in this category yet.
|
{t.settings.keys.empty}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SettingsContent>
|
</SettingsContent>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
|
import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Wrench } from '@/lib/icons'
|
import { Wrench } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@ -43,6 +44,8 @@ const transportLabel = (server: Record<string, unknown>) =>
|
|||||||
: 'custom'
|
: 'custom'
|
||||||
|
|
||||||
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const m = t.settings.mcp
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
@ -64,7 +67,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
const first = Object.keys(getServers(next)).sort()[0] ?? null
|
||||||
setSelected(first)
|
setSelected(first)
|
||||||
})
|
})
|
||||||
.catch(err => notifyError(err, 'MCP config failed to load'))
|
.catch(err => notifyError(err, m.failedLoad))
|
||||||
|
|
||||||
return () => void (cancelled = true)
|
return () => void (cancelled = true)
|
||||||
}, [])
|
}, [])
|
||||||
@ -88,14 +91,14 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
}, [selected, servers])
|
}, [selected, servers])
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <LoadingState label="Loading MCP servers..." />
|
return <LoadingState label={m.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveServer = async () => {
|
const saveServer = async () => {
|
||||||
const nextName = name.trim()
|
const nextName = name.trim()
|
||||||
|
|
||||||
if (!nextName) {
|
if (!nextName) {
|
||||||
notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' })
|
notify({ kind: 'error', title: m.nameRequiredTitle, message: m.nameRequiredMessage })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -106,12 +109,12 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
const raw = JSON.parse(body)
|
const raw = JSON.parse(body)
|
||||||
|
|
||||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
throw new Error('Server config must be a JSON object')
|
throw new Error(m.objectRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed = raw as Record<string, unknown>
|
parsed = raw as Record<string, unknown>
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Invalid MCP JSON')
|
notifyError(err, m.invalidJson)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -132,9 +135,9 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
setConfig(nextConfig)
|
setConfig(nextConfig)
|
||||||
setSelected(nextName)
|
setSelected(nextName)
|
||||||
onConfigSaved?.()
|
onConfigSaved?.()
|
||||||
notify({ kind: 'success', title: 'MCP server saved', message: `${nextName} applies after MCP reload.` })
|
notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(nextName) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Save failed')
|
notifyError(err, m.saveFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -153,7 +156,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
setSelected(Object.keys(nextServers).sort()[0] ?? null)
|
setSelected(Object.keys(nextServers).sort()[0] ?? null)
|
||||||
onConfigSaved?.()
|
onConfigSaved?.()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Remove failed')
|
notifyError(err, m.removeFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -161,7 +164,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
|
|
||||||
const reloadMcp = async () => {
|
const reloadMcp = async () => {
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' })
|
notify({ kind: 'warning', title: m.gatewayUnavailableTitle, message: m.gatewayUnavailableMessage })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -173,9 +176,9 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
confirm: true,
|
confirm: true,
|
||||||
session_id: activeSessionId ?? undefined
|
session_id: activeSessionId ?? undefined
|
||||||
})
|
})
|
||||||
notify({ kind: 'success', title: 'MCP tools reloaded', message: 'New tool schemas apply to fresh turns.' })
|
notify({ kind: 'success', title: m.reloadedTitle, message: m.reloadedMessage })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'MCP reload failed')
|
notifyError(err, m.reloadFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setReloading(false)
|
setReloading(false)
|
||||||
}
|
}
|
||||||
@ -185,17 +188,17 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
<SettingsContent>
|
<SettingsContent>
|
||||||
<div className="mb-4 flex items-center justify-end gap-4">
|
<div className="mb-4 flex items-center justify-end gap-4">
|
||||||
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
<Button onClick={() => setSelected(null)} size="xs" variant="text">
|
||||||
New server
|
{m.newServer}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
<Button disabled={reloading} onClick={() => void reloadMcp()} size="xs" variant="text">
|
||||||
{reloading ? 'Reloading...' : 'Reload MCP'}
|
{reloading ? m.reloading : m.reload}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
<div className="grid min-h-0 gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]">
|
||||||
<div className="min-h-64">
|
<div className="min-h-64">
|
||||||
{names.length === 0 ? (
|
{names.length === 0 ? (
|
||||||
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
|
<EmptyState description={m.emptyDesc} title={m.emptyTitle} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
{names.map(serverName => {
|
{names.map(serverName => {
|
||||||
@ -216,7 +219,7 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
<div className="truncate text-sm font-medium">{serverName}</div>
|
<div className="truncate text-sm font-medium">{serverName}</div>
|
||||||
<div className="mt-1 flex items-center gap-1.5">
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
<Pill>{transportLabel(server)}</Pill>
|
<Pill>{transportLabel(server)}</Pill>
|
||||||
{server.disabled === true && <Pill>disabled</Pill>}
|
{server.disabled === true && <Pill>{m.disabled}</Pill>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -228,14 +231,14 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
<div className="grid content-start gap-3">
|
<div className="grid content-start gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Wrench className="size-4 text-muted-foreground" />
|
<Wrench className="size-4 text-muted-foreground" />
|
||||||
{selected ? 'Edit server' : 'New server'}
|
{selected ? m.editServer : m.newServer}
|
||||||
</div>
|
</div>
|
||||||
<label className="grid gap-1.5">
|
<label className="grid gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">Name</span>
|
<span className="text-xs text-muted-foreground">{m.name}</span>
|
||||||
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
|
||||||
</label>
|
</label>
|
||||||
<label className="grid gap-1.5">
|
<label className="grid gap-1.5">
|
||||||
<span className="text-xs text-muted-foreground">Server JSON</span>
|
<span className="text-xs text-muted-foreground">{m.serverJson}</span>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="min-h-80 font-mono text-xs"
|
className="min-h-80 font-mono text-xs"
|
||||||
onChange={event => setBody(event.currentTarget.value)}
|
onChange={event => setBody(event.currentTarget.value)}
|
||||||
@ -252,13 +255,13 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Remove
|
{m.remove}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
<Button disabled={saving} onClick={() => void saveServer()} size="sm">
|
||||||
{saving ? 'Saving...' : 'Save server'}
|
{saving ? t.common.saving : m.saveServer}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||||
import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes'
|
import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@ -14,43 +15,34 @@ import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
|||||||
// hints make the assignments readable; raw task keys (vision, mcp, …) are
|
// hints make the assignments readable; raw task keys (vision, mcp, …) are
|
||||||
// opaque to most users.
|
// opaque to most users.
|
||||||
interface AuxTaskMeta {
|
interface AuxTaskMeta {
|
||||||
hint: string
|
|
||||||
key: string
|
key: string
|
||||||
label: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
const AUX_TASKS: readonly AuxTaskMeta[] = [
|
||||||
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
|
{ key: 'vision' },
|
||||||
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
|
{ key: 'web_extract' },
|
||||||
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
|
{ key: 'compression' },
|
||||||
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
|
{ key: 'skills_hub' },
|
||||||
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
|
{ key: 'approval' },
|
||||||
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
|
{ key: 'mcp' },
|
||||||
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
|
{ key: 'title_generation' },
|
||||||
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
|
{ key: 'curator' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
|
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
|
||||||
|
|
||||||
const AUX_TASK_LABELS: Record<string, string> = Object.fromEntries(
|
|
||||||
AUX_TASKS.map(meta => [meta.key, meta.label])
|
|
||||||
)
|
|
||||||
|
|
||||||
function taskLabel(key: string): string {
|
|
||||||
return AUX_TASK_LABELS[key] ?? key
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaleAuxWarningProps {
|
interface StaleAuxWarningProps {
|
||||||
applying: boolean
|
applying: boolean
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
slots: readonly StaleAuxAssignment[]
|
slots: readonly StaleAuxAssignment[]
|
||||||
|
taskLabel: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared notice: auxiliary tasks still pinned to a provider that isn't the
|
// Shared notice: auxiliary tasks still pinned to a provider that isn't the
|
||||||
// current main. Surfaces the silent credit-burn path (e.g. aux pinned to a
|
// current main. Surfaces the silent credit-burn path (e.g. aux pinned to a
|
||||||
// $0-balance provider after switching main away from it) and offers the
|
// $0-balance provider after switching main away from it) and offers the
|
||||||
// existing one-click reset rather than auto-clearing legitimate pins.
|
// existing one-click reset rather than auto-clearing legitimate pins.
|
||||||
function StaleAuxWarning({ applying, onReset, slots }: StaleAuxWarningProps) {
|
function StaleAuxWarning({ applying, onReset, slots, taskLabel }: StaleAuxWarningProps) {
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -79,6 +71,8 @@ interface ModelSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const m = t.settings.model
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||||
@ -132,6 +126,8 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
[auxDraft.provider, providers]
|
[auxDraft.provider, providers]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const auxiliaryTaskLabel = useCallback((key: string) => m.tasks[key]?.label ?? key, [m.tasks])
|
||||||
|
|
||||||
// Persistent mismatch: any aux slot pinned to a provider different from the
|
// Persistent mismatch: any aux slot pinned to a provider different from the
|
||||||
// current main, regardless of whether the user just switched. Catches the
|
// current main, regardless of whether the user just switched. Catches the
|
||||||
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
|
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
|
||||||
@ -253,19 +249,19 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
}, [mainModel, refresh])
|
}, [mainModel, refresh])
|
||||||
|
|
||||||
if (loading && !mainModel) {
|
if (loading && !mainModel) {
|
||||||
return <LoadingState label="Loading model configuration..." />
|
return <LoadingState label={m.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section>
|
<section>
|
||||||
<p className="mb-3 text-xs text-muted-foreground">
|
<p className="mb-3 text-xs text-muted-foreground">
|
||||||
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
|
{m.appliesDesc}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
|
||||||
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
|
||||||
<SelectValue placeholder="Provider" />
|
<SelectValue placeholder={m.provider} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{providerOptions.map(provider => (
|
{providerOptions.map(provider => (
|
||||||
@ -277,7 +273,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
<Select onValueChange={setSelectedModel} value={selectedModel}>
|
||||||
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
|
||||||
<SelectValue placeholder="Model" />
|
<SelectValue placeholder={m.model} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
|
||||||
@ -293,39 +289,50 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
{applying ? 'Applying...' : 'Apply'}
|
{applying ? m.applying : t.common.apply}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||||
{switchStaleAux.length > 0 && (
|
{switchStaleAux.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<StaleAuxWarning applying={applying} onReset={() => void resetAuxiliaryModels()} slots={switchStaleAux} />
|
<StaleAuxWarning
|
||||||
|
applying={applying}
|
||||||
|
onReset={() => void resetAuxiliaryModels()}
|
||||||
|
slots={switchStaleAux}
|
||||||
|
taskLabel={auxiliaryTaskLabel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2.5 flex items-center justify-between">
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
<SectionHeading icon={Cpu} title="Auxiliary models" />
|
<SectionHeading icon={Cpu} title={m.auxiliaryTitle} />
|
||||||
<Button
|
<Button
|
||||||
disabled={!mainModel || applying}
|
disabled={!mainModel || applying}
|
||||||
onClick={() => void resetAuxiliaryModels()}
|
onClick={() => void resetAuxiliaryModels()}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
Reset all to main
|
{m.resetAllToMain}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-2 text-xs text-muted-foreground">
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
|
{m.auxiliaryDesc}
|
||||||
</p>
|
</p>
|
||||||
{switchStaleAux.length === 0 && persistentStaleAux.length > 0 && (
|
{switchStaleAux.length === 0 && persistentStaleAux.length > 0 && (
|
||||||
<div className="mb-2.5">
|
<div className="mb-2.5">
|
||||||
<StaleAuxWarning applying={applying} onReset={() => void resetAuxiliaryModels()} slots={persistentStaleAux} />
|
<StaleAuxWarning
|
||||||
|
applying={applying}
|
||||||
|
onReset={() => void resetAuxiliaryModels()}
|
||||||
|
slots={persistentStaleAux}
|
||||||
|
taskLabel={auxiliaryTaskLabel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{AUX_TASKS.map(meta => {
|
{AUX_TASKS.map(meta => {
|
||||||
|
const copy = m.tasks[meta.key] ?? { label: meta.key, hint: meta.key }
|
||||||
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
|
||||||
const isAuto = !current || !current.provider || current.provider === 'auto'
|
const isAuto = !current || !current.provider || current.provider === 'auto'
|
||||||
const isEditing = editingAuxTask === meta.key
|
const isEditing = editingAuxTask === meta.key
|
||||||
@ -341,7 +348,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
Set to main
|
{m.setToMain}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!providers.length || applying}
|
disabled={!providers.length || applying}
|
||||||
@ -349,7 +356,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
Change
|
{m.change}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -362,7 +369,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
value={auxDraft.provider}
|
value={auxDraft.provider}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
|
||||||
<SelectValue placeholder="Provider" />
|
<SelectValue placeholder={m.provider} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{providerOptions.map(provider => (
|
{providerOptions.map(provider => (
|
||||||
@ -377,7 +384,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
value={auxDraft.model}
|
value={auxDraft.model}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
|
||||||
<SelectValue placeholder="Model" />
|
<SelectValue placeholder={m.model} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
|
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
|
||||||
@ -392,10 +399,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
onClick={() => void applyAuxiliaryDraft(meta.key)}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{applying ? 'Applying...' : 'Apply'}
|
{applying ? m.applying : t.common.apply}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -403,15 +410,15 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
|||||||
description={
|
description={
|
||||||
<span className="font-mono text-[0.68rem]">
|
<span className="font-mono text-[0.68rem]">
|
||||||
{isAuto
|
{isAuto
|
||||||
? 'auto · use main model'
|
? m.autoUseMain
|
||||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
: `${current.provider} · ${current.model || m.providerDefault}`}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
key={meta.key}
|
key={meta.key}
|
||||||
title={
|
title={
|
||||||
<span className="flex items-baseline gap-2">
|
<span className="flex items-baseline gap-2">
|
||||||
{meta.label}
|
{copy.label}
|
||||||
<Pill>{meta.hint}</Pill>
|
<Pill>{copy.hint}</Pill>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/components/desktop-onboarding-overlay'
|
} from '@/components/desktop-onboarding-overlay'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { listOAuthProviders } from '@/hermes'
|
import { listOAuthProviders } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||||
@ -85,6 +86,8 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
|||||||
// that provider's real sign-in flow; the key affordances open the API-key
|
// that provider's real sign-in flow; the key affordances open the API-key
|
||||||
// catalog below.
|
// catalog below.
|
||||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const p = t.settings.providers
|
||||||
const [showAll, setShowAll] = useState(false)
|
const [showAll, setShowAll] = useState(false)
|
||||||
const ordered = useMemo(() => sortProviders(providers), [providers])
|
const ordered = useMemo(() => sortProviders(providers), [providers])
|
||||||
|
|
||||||
@ -106,25 +109,24 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
|||||||
return (
|
return (
|
||||||
<section className="mb-5 grid gap-2">
|
<section className="mb-5 grid gap-2">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||||
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
|
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
|
||||||
<Button
|
<Button
|
||||||
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
||||||
onClick={onWantApiKey}
|
onClick={onWantApiKey}
|
||||||
type="button"
|
type="button"
|
||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
Have an API key instead?
|
{p.haveApiKey}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||||
Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
|
{p.intro}
|
||||||
app.
|
|
||||||
</p>
|
</p>
|
||||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||||
{connected.length > 0 && (
|
{connected.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||||
Connected
|
{p.connected}
|
||||||
</p>
|
</p>
|
||||||
{connected.map(p => (
|
{connected.map(p => (
|
||||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||||
@ -146,7 +148,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
|||||||
type="button"
|
type="button"
|
||||||
variant="text"
|
variant="text"
|
||||||
>
|
>
|
||||||
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
|
{showAll ? p.collapse : connected.length > 0 ? p.connectAnother : p.otherProviders}
|
||||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -155,14 +157,17 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NoProviderKeys() {
|
function NoProviderKeys() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||||
No provider API keys available.
|
{t.settings.providers.noProviderKeys}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { rowProps, vars } = useEnvCredentials()
|
const { rowProps, vars } = useEnvCredentials()
|
||||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||||
@ -195,7 +200,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
|||||||
}, [onboardingActive])
|
}, [onboardingActive])
|
||||||
|
|
||||||
if (!vars) {
|
if (!vars) {
|
||||||
return <LoadingState label="Loading providers..." />
|
return <LoadingState label={t.settings.providers.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOauth = oauthProviders.length > 0
|
const hasOauth = oauthProviders.length > 0
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { sessionTitle } from '@/lib/chat-runtime'
|
import { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||||
@ -32,6 +33,8 @@ function workspaceLabel(cwd: null | string | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionsSettings() {
|
export function SessionsSettings() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const s = t.settings.sessions
|
||||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
@ -43,7 +46,7 @@ export function SessionsSettings() {
|
|||||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||||
setLocalSessions(result.sessions)
|
setLocalSessions(result.sessions)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not load archived sessions')
|
notifyError(err, s.failedLoad)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -62,16 +65,16 @@ export function SessionsSettings() {
|
|||||||
// Surface it again in the sidebar without waiting for a full refresh.
|
// Surface it again in the sidebar without waiting for a full refresh.
|
||||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||||
triggerHaptic('selection')
|
triggerHaptic('selection')
|
||||||
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
|
notify({ durationMs: 2_000, kind: 'success', message: s.restored })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Unarchive failed')
|
notifyError(err, s.unarchiveFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null)
|
setBusyId(null)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [s])
|
||||||
|
|
||||||
const remove = useCallback(async (session: SessionInfo) => {
|
const remove = useCallback(async (session: SessionInfo) => {
|
||||||
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
|
if (!window.confirm(s.deleteConfirm(sessionTitle(session)))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,11 +85,11 @@ export function SessionsSettings() {
|
|||||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||||
triggerHaptic('warning')
|
triggerHaptic('warning')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Delete failed')
|
notifyError(err, s.deleteFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null)
|
setBusyId(null)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [s])
|
||||||
|
|
||||||
useDeepLinkHighlight({
|
useDeepLinkHighlight({
|
||||||
elementId: id => `archived-session-${id}`,
|
elementId: id => `archived-session-${id}`,
|
||||||
@ -95,7 +98,7 @@ export function SessionsSettings() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingState label="Loading archived sessions…" />
|
return <LoadingState label={s.loading} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -105,15 +108,14 @@ export function SessionsSettings() {
|
|||||||
<SectionHeading
|
<SectionHeading
|
||||||
icon={Archive}
|
icon={Archive}
|
||||||
meta={sessions.length ? String(sessions.length) : undefined}
|
meta={sessions.length ? String(sessions.length) : undefined}
|
||||||
title="Archived sessions"
|
title={s.archivedTitle}
|
||||||
/>
|
/>
|
||||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
|
{s.archivedIntro}
|
||||||
archive it.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<EmptyState description="Archive a chat to hide it here." title="Nothing archived" />
|
<EmptyState description={s.emptyArchivedDesc} title={s.emptyArchivedTitle} />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{sessions.map(session => {
|
{sessions.map(session => {
|
||||||
@ -133,11 +135,11 @@ export function SessionsSettings() {
|
|||||||
variant="textStrong"
|
variant="textStrong"
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||||
<span>Unarchive</span>
|
<span>{s.unarchive}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Tip label="Delete permanently">
|
<Tip label={s.deletePermanently}>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Delete permanently"
|
aria-label={s.deletePermanently}
|
||||||
className="text-muted-foreground hover:text-destructive"
|
className="text-muted-foreground hover:text-destructive"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void remove(session)}
|
onClick={() => void remove(session)}
|
||||||
@ -151,7 +153,7 @@ export function SessionsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description={session.preview || undefined}
|
description={session.preview || undefined}
|
||||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
hint={label ? `${label} · ${s.messages(session.message_count)}` : s.messages(session.message_count)}
|
||||||
title={sessionTitle(session)}
|
title={sessionTitle(session)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -167,6 +169,8 @@ export function SessionsSettings() {
|
|||||||
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
||||||
// / Program Files), which buried any files Hermes wrote there.
|
// / Program Files), which buried any files Hermes wrote there.
|
||||||
function DefaultProjectDirSetting() {
|
function DefaultProjectDirSetting() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const s = t.settings.sessions
|
||||||
const [dir, setDir] = useState<null | string>(null)
|
const [dir, setDir] = useState<null | string>(null)
|
||||||
const [fallback, setFallback] = useState<string>('')
|
const [fallback, setFallback] = useState<string>('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@ -217,13 +221,13 @@ function DefaultProjectDirSetting() {
|
|||||||
|
|
||||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||||
setDir(result.dir)
|
setDir(result.dir)
|
||||||
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
|
notify({ durationMs: 2_000, kind: 'success', message: s.defaultDirUpdated })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not update default directory')
|
notifyError(err, s.updateDirFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [s])
|
||||||
|
|
||||||
const clear = useCallback(async () => {
|
const clear = useCallback(async () => {
|
||||||
const settings = window.hermesDesktop?.settings
|
const settings = window.hermesDesktop?.settings
|
||||||
@ -238,34 +242,34 @@ function DefaultProjectDirSetting() {
|
|||||||
await settings.setDefaultProjectDir(null)
|
await settings.setDefaultProjectDir(null)
|
||||||
setDir(null)
|
setDir(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not clear default directory')
|
notifyError(err, s.clearDirFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [s])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<SectionHeading icon={FolderOpen} title="Default project directory" />
|
<SectionHeading icon={FolderOpen} title={s.defaultDirTitle} />
|
||||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||||
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
|
{s.defaultDirDesc}
|
||||||
</p>
|
</p>
|
||||||
<ListRow
|
<ListRow
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
|
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
|
||||||
<FolderOpen className="size-3.5" />
|
<FolderOpen className="size-3.5" />
|
||||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
<span>{dir ? s.change : s.choose}</span>
|
||||||
</Button>
|
</Button>
|
||||||
{dir && (
|
{dir && (
|
||||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
|
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="text">
|
||||||
Clear
|
{s.clear}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
|
description={dir || s.defaultsTo(fallback || '~/hermes-projects')}
|
||||||
title={dir ? dir : 'Not set'}
|
title={dir ? dir : s.notSet}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { PageLoader } from '@/components/page-loader'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Check, Loader2, Save } from '@/lib/icons'
|
import { Check, Loader2, Save } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@ -35,6 +36,8 @@ interface EnvVarFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.toolsets
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [revealed, setRevealed] = useState<string | null>(null)
|
const [revealed, setRevealed] = useState<string | null>(null)
|
||||||
@ -52,16 +55,16 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
setEditing(false)
|
setEditing(false)
|
||||||
setValue('')
|
setValue('')
|
||||||
onSaved(envVar.key)
|
onSaved(envVar.key)
|
||||||
notify({ kind: 'success', title: 'Credential saved', message: `${envVar.key} updated.` })
|
notify({ kind: 'success', title: copy.savedTitle, message: copy.savedMessage(envVar.key) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to save ${envVar.key}`)
|
notifyError(err, copy.failedSave(envVar.key))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClear() {
|
async function handleClear() {
|
||||||
if (!window.confirm(`Remove ${envVar.key} from .env?`)) {
|
if (!window.confirm(copy.removeConfirm(envVar.key))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,9 +74,9 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
await deleteEnvVar(envVar.key)
|
await deleteEnvVar(envVar.key)
|
||||||
setRevealed(null)
|
setRevealed(null)
|
||||||
onCleared(envVar.key)
|
onCleared(envVar.key)
|
||||||
notify({ kind: 'success', title: 'Credential removed', message: `${envVar.key} removed.` })
|
notify({ kind: 'success', title: copy.removedTitle, message: copy.removedMessage(envVar.key) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to remove ${envVar.key}`)
|
notifyError(err, copy.failedRemove(envVar.key))
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@ -90,7 +93,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
const result = await revealEnvVar(envVar.key)
|
const result = await revealEnvVar(envVar.key)
|
||||||
setRevealed(result.value)
|
setRevealed(result.value)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to reveal ${envVar.key}`)
|
notifyError(err, copy.failedReveal(envVar.key))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +105,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
||||||
<Pill tone={isSet ? 'primary' : 'muted'}>
|
<Pill tone={isSet ? 'primary' : 'muted'}>
|
||||||
{isSet && <Check className="size-3" />}
|
{isSet && <Check className="size-3" />}
|
||||||
{isSet ? 'Set' : 'Not set'}
|
{isSet ? copy.set : copy.notSet}
|
||||||
</Pill>
|
</Pill>
|
||||||
</div>
|
</div>
|
||||||
{envVar.prompt && envVar.prompt !== envVar.key && (
|
{envVar.prompt && envVar.prompt !== envVar.key && (
|
||||||
@ -143,10 +146,10 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
/>
|
/>
|
||||||
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||||
Save
|
{t.common.save}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
<Button onClick={() => setEditing(false)} size="sm" variant="text">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -155,6 +158,8 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.settings.toolsets
|
||||||
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
|
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [selecting, setSelecting] = useState<string | null>(null)
|
const [selecting, setSelecting] = useState<string | null>(null)
|
||||||
@ -178,7 +183,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
|
|
||||||
setEnvState(seeded)
|
setEnvState(seeded)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Tool configuration failed to load')
|
notifyError(err, copy.failedLoad)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -215,10 +220,10 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await selectToolsetProvider(toolset, provider.name)
|
await selectToolsetProvider(toolset, provider.name)
|
||||||
notify({ kind: 'success', title: 'Provider selected', message: `${provider.name} is now active.` })
|
notify({ kind: 'success', title: copy.selectedTitle, message: copy.selectedMessage(provider.name) })
|
||||||
onConfiguredChange?.()
|
onConfiguredChange?.()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, `Failed to select ${provider.name}`)
|
notifyError(err, copy.failedSelect(provider.name))
|
||||||
} finally {
|
} finally {
|
||||||
setSelecting(null)
|
setSelecting(null)
|
||||||
}
|
}
|
||||||
@ -235,18 +240,18 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!cfg.has_category) {
|
if (!cfg.has_category) {
|
||||||
return 'This toolset has no provider options — enable it and it works with your current setup.'
|
return copy.noProviderOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
return 'No providers are available for this toolset right now.'
|
return copy.noProviders
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}, [cfg, loading, providers.length])
|
}, [cfg, copy, loading, providers.length])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <PageLoader className="min-h-32" label="Loading configuration" />
|
return <PageLoader className="min-h-32" label={copy.loadingConfig} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emptyMessage) {
|
if (emptyMessage) {
|
||||||
@ -276,7 +281,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
{configured && (
|
{configured && (
|
||||||
<Pill tone="primary">
|
<Pill tone="primary">
|
||||||
<Check className="size-3" />
|
<Check className="size-3" />
|
||||||
Ready
|
{copy.ready}
|
||||||
</Pill>
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@ -288,11 +293,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
||||||
{provider.requires_nous_auth && (
|
{provider.requires_nous_auth && (
|
||||||
<p className="text-[0.72rem] text-muted-foreground">
|
<p className="text-[0.72rem] text-muted-foreground">
|
||||||
Included with a Nous subscription — sign in to Nous Portal to activate.
|
{copy.nousIncluded}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{provider.env_vars.length === 0 ? (
|
{provider.env_vars.length === 0 ? (
|
||||||
<p className="text-[0.72rem] text-muted-foreground">No API key required.</p>
|
<p className="text-[0.72rem] text-muted-foreground">{copy.noApiKeyRequired}</p>
|
||||||
) : (
|
) : (
|
||||||
provider.env_vars.map(ev => (
|
provider.env_vars.map(ev => (
|
||||||
<EnvVarField
|
<EnvVarField
|
||||||
@ -306,8 +311,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
|
|||||||
)}
|
)}
|
||||||
{provider.post_setup && (
|
{provider.post_setup && (
|
||||||
<p className="text-[0.72rem] text-muted-foreground">
|
<p className="text-[0.72rem] text-muted-foreground">
|
||||||
This provider needs an extra setup step ({provider.post_setup}). Run it from the CLI with{' '}
|
{copy.postSetup(provider.post_setup)}
|
||||||
<code className="font-mono">hermes tools</code> for now.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { IconLayoutDashboard } from '@tabler/icons-react'
|
|||||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tip } from '@/components/ui/tooltip'
|
import { Tip } from '@/components/ui/tooltip'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Activity, AlertCircle } from '@/lib/icons'
|
import { Activity, AlertCircle } from '@/lib/icons'
|
||||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -40,23 +41,25 @@ export function GatewayMenuPanel({
|
|||||||
onOpenSystem,
|
onOpenSystem,
|
||||||
statusSnapshot
|
statusSnapshot
|
||||||
}: GatewayMenuPanelProps) {
|
}: GatewayMenuPanelProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.shell.gatewayMenu
|
||||||
const gatewayOpen = gatewayState === 'open'
|
const gatewayOpen = gatewayState === 'open'
|
||||||
const gatewayConnecting = gatewayState === 'connecting'
|
const gatewayConnecting = gatewayState === 'connecting'
|
||||||
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
|
||||||
|
|
||||||
const connectionLabel = gatewayOpen
|
const connectionLabel = gatewayOpen
|
||||||
? 'Connected'
|
? copy.connected
|
||||||
: gatewayConnecting
|
: gatewayConnecting
|
||||||
? 'Connecting'
|
? copy.connecting
|
||||||
: prettyState(gatewayState || 'offline')
|
: prettyState(gatewayState || copy.offline)
|
||||||
|
|
||||||
const inferenceLabel = gatewayOpen
|
const inferenceLabel = gatewayOpen
|
||||||
? inferenceStatus?.ready
|
? inferenceStatus?.ready
|
||||||
? 'Inference ready'
|
? copy.inferenceReady
|
||||||
: inferenceStatus
|
: inferenceStatus
|
||||||
? 'Inference not ready'
|
? copy.inferenceNotReady
|
||||||
: 'Checking inference'
|
: copy.checkingInference
|
||||||
: 'Disconnected'
|
: copy.disconnected
|
||||||
|
|
||||||
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
|
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
|
||||||
const recentLogs = logLines.slice(-5)
|
const recentLogs = logLines.slice(-5)
|
||||||
@ -70,16 +73,16 @@ export function GatewayMenuPanel({
|
|||||||
) : (
|
) : (
|
||||||
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
|
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">Gateway</span>
|
<span className="font-medium">{copy.gateway}</span>
|
||||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
|
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
|
||||||
{inferenceLabel}
|
{inferenceLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Tip label="Open system panel">
|
<Tip label={copy.openSystem}>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Open system panel"
|
aria-label={copy.openSystem}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={onOpenSystem}
|
onClick={onOpenSystem}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@ -92,13 +95,13 @@ export function GatewayMenuPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<div>Connection: {connectionLabel}</div>
|
<div>{copy.connection(connectionLabel)}</div>
|
||||||
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
|
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentLogs.length > 0 && (
|
{recentLogs.length > 0 && (
|
||||||
<div className="border-t border-border/50 px-3 py-2">
|
<div className="border-t border-border/50 px-3 py-2">
|
||||||
<SectionLabel>Recent activity</SectionLabel>
|
<SectionLabel>{copy.recentActivity}</SectionLabel>
|
||||||
<ul className="mt-1.5 space-y-0.5">
|
<ul className="mt-1.5 space-y-0.5">
|
||||||
{recentLogs.map((line, index) => (
|
{recentLogs.map((line, index) => (
|
||||||
<Tip key={`${index}:${line}`} label={line.trim()}>
|
<Tip key={`${index}:${line}`} label={line.trim()}>
|
||||||
@ -113,14 +116,14 @@ export function GatewayMenuPanel({
|
|||||||
onClick={onOpenSystem}
|
onClick={onOpenSystem}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
View all logs →
|
{copy.viewAllLogs}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{platforms.length > 0 && (
|
{platforms.length > 0 && (
|
||||||
<div className="border-t border-border/50 px-3 py-2">
|
<div className="border-t border-border/50 px-3 py-2">
|
||||||
<SectionLabel>Messaging platforms</SectionLabel>
|
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
|
||||||
<ul className="mt-1.5 space-y-1">
|
<ul className="mt-1.5 space-y-1">
|
||||||
{platforms.map(([name, platform]) => (
|
{platforms.map(([name, platform]) => (
|
||||||
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
|
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
Zap,
|
Zap,
|
||||||
ZapFilled
|
ZapFilled
|
||||||
} from '@/lib/icons'
|
} from '@/lib/icons'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||||
@ -78,6 +79,8 @@ export function useStatusbarItems({
|
|||||||
statusSnapshot,
|
statusSnapshot,
|
||||||
toggleCommandCenter
|
toggleCommandCenter
|
||||||
}: StatusbarItemsOptions) {
|
}: StatusbarItemsOptions) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.shell.statusbar
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
const yoloActive = useStore($yoloActive)
|
const yoloActive = useStore($yoloActive)
|
||||||
const busy = useStore($busy)
|
const busy = useStore($busy)
|
||||||
@ -160,13 +163,13 @@ export function useStatusbarItems({
|
|||||||
|
|
||||||
const gatewayDetail = gatewayOpen
|
const gatewayDetail = gatewayOpen
|
||||||
? inferenceStatus?.ready
|
? inferenceStatus?.ready
|
||||||
? 'ready'
|
? copy.gatewayReady
|
||||||
: inferenceStatus
|
: inferenceStatus
|
||||||
? 'needs setup'
|
? copy.gatewayNeedsSetup
|
||||||
: 'checking'
|
: copy.gatewayChecking
|
||||||
: gatewayConnecting
|
: gatewayConnecting
|
||||||
? 'connecting'
|
? copy.gatewayConnecting
|
||||||
: 'offline'
|
: copy.gatewayOffline
|
||||||
|
|
||||||
const gatewayClassName = inferenceReady
|
const gatewayClassName = inferenceReady
|
||||||
? undefined
|
? undefined
|
||||||
@ -179,21 +182,21 @@ export function useStatusbarItems({
|
|||||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||||
const behind = updateStatus?.behind ?? 0
|
const behind = updateStatus?.behind ?? 0
|
||||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||||
const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
|
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||||
|
|
||||||
const label = applying
|
const label = applying
|
||||||
? updateApply.stage === 'restart'
|
? updateApply.stage === 'restart'
|
||||||
? `${base} · restart`
|
? `${base} · ${copy.restart}`
|
||||||
: `${base} · update`
|
: `${base} · ${copy.update}`
|
||||||
: `${base}${behindHint}`
|
: `${base}${behindHint}`
|
||||||
|
|
||||||
const tooltip = [
|
const tooltip = [
|
||||||
applying ? updateApply.message || 'Update in progress' : null,
|
applying ? updateApply.message || copy.updateInProgress : null,
|
||||||
!applying && behind > 0 && `${behind} commit${behind === 1 ? '' : 's'} behind ${updateStatus?.branch ?? '…'}`,
|
!applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'),
|
||||||
appVersion && `Hermes Desktop v${appVersion}`,
|
appVersion && copy.desktopVersion(appVersion),
|
||||||
sha && `commit ${sha}`,
|
sha && copy.commit(sha),
|
||||||
updateStatus?.branch && `branch ${updateStatus.branch}`
|
updateStatus?.branch && copy.branch(updateStatus.branch)
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
@ -211,6 +214,7 @@ export function useStatusbarItems({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
desktopVersion?.appVersion,
|
desktopVersion?.appVersion,
|
||||||
|
copy,
|
||||||
updateApply.applying,
|
updateApply.applying,
|
||||||
updateApply.message,
|
updateApply.message,
|
||||||
updateApply.stage,
|
updateApply.stage,
|
||||||
@ -226,7 +230,7 @@ export function useStatusbarItems({
|
|||||||
icon: <Command className="size-3.5" />,
|
icon: <Command className="size-3.5" />,
|
||||||
id: 'command-center',
|
id: 'command-center',
|
||||||
onSelect: toggleCommandCenter,
|
onSelect: toggleCommandCenter,
|
||||||
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
|
title: commandCenterOpen ? copy.closeCommandCenter : copy.openCommandCenter,
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -234,10 +238,10 @@ export function useStatusbarItems({
|
|||||||
detail: gatewayDetail,
|
detail: gatewayDetail,
|
||||||
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
icon: inferenceReady ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
||||||
id: 'gateway-health',
|
id: 'gateway-health',
|
||||||
label: 'Gateway',
|
label: copy.gateway,
|
||||||
menuClassName: 'w-72',
|
menuClassName: 'w-72',
|
||||||
menuContent: gatewayMenuContent,
|
menuContent: gatewayMenuContent,
|
||||||
title: inferenceStatus?.reason || 'Hermes inference gateway status',
|
title: inferenceStatus?.reason || copy.gatewayTitle,
|
||||||
variant: 'menu'
|
variant: 'menu'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -247,11 +251,11 @@ export function useStatusbarItems({
|
|||||||
),
|
),
|
||||||
detail:
|
detail:
|
||||||
subagentsRunning > 0
|
subagentsRunning > 0
|
||||||
? `${subagentsRunning} subagent${subagentsRunning === 1 ? '' : 's'}`
|
? copy.subagents(subagentsRunning)
|
||||||
: bgFailed > 0
|
: bgFailed > 0
|
||||||
? `${bgFailed} failed`
|
? copy.failed(bgFailed)
|
||||||
: bgRunning > 0
|
: bgRunning > 0
|
||||||
? `${bgRunning} running`
|
? copy.running(bgRunning)
|
||||||
: undefined,
|
: undefined,
|
||||||
icon:
|
icon:
|
||||||
bgFailed > 0 ? (
|
bgFailed > 0 ? (
|
||||||
@ -262,16 +266,16 @@ export function useStatusbarItems({
|
|||||||
<Sparkles className="size-3" />
|
<Sparkles className="size-3" />
|
||||||
),
|
),
|
||||||
id: 'agents',
|
id: 'agents',
|
||||||
label: 'Agents',
|
label: copy.agents,
|
||||||
onSelect: openAgents,
|
onSelect: openAgents,
|
||||||
title: agentsOpen ? 'Close agents' : 'Open agents',
|
title: agentsOpen ? copy.closeAgents : copy.openAgents,
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Clock className="size-3" />,
|
icon: <Clock className="size-3" />,
|
||||||
id: 'cron',
|
id: 'cron',
|
||||||
label: 'Cron',
|
label: copy.cron,
|
||||||
title: 'Open cron jobs',
|
title: copy.openCron,
|
||||||
to: CRON_ROUTE,
|
to: CRON_ROUTE,
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
}
|
}
|
||||||
@ -281,6 +285,7 @@ export function useStatusbarItems({
|
|||||||
bgFailed,
|
bgFailed,
|
||||||
bgRunning,
|
bgRunning,
|
||||||
commandCenterOpen,
|
commandCenterOpen,
|
||||||
|
copy,
|
||||||
gatewayMenuContent,
|
gatewayMenuContent,
|
||||||
gatewayClassName,
|
gatewayClassName,
|
||||||
gatewayDetail,
|
gatewayDetail,
|
||||||
@ -299,8 +304,8 @@ export function useStatusbarItems({
|
|||||||
hidden: !busy || !turnStartedAt,
|
hidden: !busy || !turnStartedAt,
|
||||||
icon: <Loader2 className="size-3 animate-spin" />,
|
icon: <Loader2 className="size-3 animate-spin" />,
|
||||||
id: 'running-timer',
|
id: 'running-timer',
|
||||||
label: 'Running',
|
label: copy.turnRunning,
|
||||||
title: 'Current turn elapsed',
|
title: copy.currentTurnElapsed,
|
||||||
variant: 'text'
|
variant: 'text'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -308,15 +313,15 @@ export function useStatusbarItems({
|
|||||||
hidden: !contextUsage,
|
hidden: !contextUsage,
|
||||||
id: 'context-usage',
|
id: 'context-usage',
|
||||||
label: contextUsage,
|
label: contextUsage,
|
||||||
title: 'Context usage',
|
title: copy.contextUsage,
|
||||||
variant: 'text'
|
variant: 'text'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
detail: <LiveDuration since={sessionStartedAt} />,
|
detail: <LiveDuration since={sessionStartedAt} />,
|
||||||
hidden: !sessionStartedAt,
|
hidden: !sessionStartedAt,
|
||||||
id: 'session-timer',
|
id: 'session-timer',
|
||||||
label: 'Session',
|
label: copy.session,
|
||||||
title: 'Runtime session elapsed',
|
title: copy.runtimeSessionElapsed,
|
||||||
variant: 'text'
|
variant: 'text'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -329,9 +334,7 @@ export function useStatusbarItems({
|
|||||||
),
|
),
|
||||||
id: 'yolo',
|
id: 'yolo',
|
||||||
onSelect: () => void toggleYolo(),
|
onSelect: () => void toggleYolo(),
|
||||||
title: yoloActive
|
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||||
? 'YOLO on — auto-approving dangerous commands. Click to turn off.'
|
|
||||||
: 'YOLO off — click to auto-approve dangerous commands.',
|
|
||||||
variant: 'action'
|
variant: 'action'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -352,12 +355,16 @@ export function useStatusbarItems({
|
|||||||
menuAlign: 'end' as const,
|
menuAlign: 'end' as const,
|
||||||
menuClassName: 'w-64',
|
menuClassName: 'w-64',
|
||||||
menuContent: modelMenuContent,
|
menuContent: modelMenuContent,
|
||||||
title: currentProvider ? `Model · ${currentProvider}: ${currentModel || 'none'}` : 'Switch model',
|
title: currentProvider
|
||||||
|
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
||||||
|
: copy.switchModel,
|
||||||
variant: 'menu' as const
|
variant: 'menu' as const
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
onSelect: () => setModelPickerOpen(true),
|
onSelect: () => setModelPickerOpen(true),
|
||||||
title: currentProvider ? `${currentProvider} · ${currentModel || 'no model'}` : 'Open model picker',
|
title: currentProvider
|
||||||
|
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
|
||||||
|
: copy.openModelPicker,
|
||||||
variant: 'action' as const
|
variant: 'action' as const
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -367,6 +374,7 @@ export function useStatusbarItems({
|
|||||||
busy,
|
busy,
|
||||||
contextBar,
|
contextBar,
|
||||||
contextUsage,
|
contextUsage,
|
||||||
|
copy,
|
||||||
currentFastMode,
|
currentFastMode,
|
||||||
currentModel,
|
currentModel,
|
||||||
currentProvider,
|
currentProvider,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuSubContent
|
DropdownMenuSubContent
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
@ -22,11 +23,11 @@ import {
|
|||||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||||
// by the Thinking toggle, not the radio.
|
// by the Thinking toggle, not the radio.
|
||||||
const EFFORT_OPTIONS = [
|
const EFFORT_OPTIONS = [
|
||||||
{ value: 'minimal', label: 'Minimal' },
|
{ value: 'minimal', labelKey: 'minimal' },
|
||||||
{ value: 'low', label: 'Low' },
|
{ value: 'low', labelKey: 'low' },
|
||||||
{ value: 'medium', label: 'Medium' },
|
{ value: 'medium', labelKey: 'medium' },
|
||||||
{ value: 'high', label: 'High' },
|
{ value: 'high', labelKey: 'high' },
|
||||||
{ value: 'xhigh', label: 'Max' }
|
{ value: 'xhigh', labelKey: 'max' }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** How "fast" is achieved for a given model — two different mechanisms:
|
/** How "fast" is achieved for a given model — two different mechanisms:
|
||||||
@ -97,6 +98,8 @@ export function ModelEditSubmenu({
|
|||||||
reasoning,
|
reasoning,
|
||||||
requestGateway
|
requestGateway
|
||||||
}: ModelEditSubmenuProps) {
|
}: ModelEditSubmenuProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.shell.modelOptions
|
||||||
// Reactive session state comes straight from the stores rather than being
|
// Reactive session state comes straight from the stores rather than being
|
||||||
// drilled through the panel, so editing it re-renders only this submenu.
|
// drilled through the panel, so editing it re-renders only this submenu.
|
||||||
const activeSessionId = useStore($activeSessionId)
|
const activeSessionId = useStore($activeSessionId)
|
||||||
@ -133,7 +136,7 @@ export function ModelEditSubmenu({
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentReasoningEffort(rollback)
|
setCurrentReasoningEffort(rollback)
|
||||||
notifyError(err, 'Model option update failed')
|
notifyError(err, copy.updateFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +166,7 @@ export function ModelEditSubmenu({
|
|||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCurrentFastMode(!enabled)
|
setCurrentFastMode(!enabled)
|
||||||
notifyError(err, 'Fast mode update failed')
|
notifyError(err, copy.fastFailed)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
@ -175,13 +178,13 @@ export function ModelEditSubmenu({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||||
{!hasFast && !reasoning ? (
|
{!hasFast && !reasoning ? (
|
||||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
|
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
||||||
{reasoning ? (
|
{reasoning ? (
|
||||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||||
Thinking
|
{copy.thinking}
|
||||||
<Switch
|
<Switch
|
||||||
checked={thinkingOn}
|
checked={thinkingOn}
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
@ -194,14 +197,14 @@ export function ModelEditSubmenu({
|
|||||||
) : null}
|
) : null}
|
||||||
{hasFast ? (
|
{hasFast ? (
|
||||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||||
Fast
|
{copy.fast}
|
||||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{reasoning ? (
|
{reasoning ? (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator className="mx-0" />
|
<DropdownMenuSeparator className="mx-0" />
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
|
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||||
value={effort}
|
value={effort}
|
||||||
@ -213,7 +216,7 @@ export function ModelEditSubmenu({
|
|||||||
onSelect={event => event.preventDefault()}
|
onSelect={event => event.preventDefault()}
|
||||||
value={option.value}
|
value={option.value}
|
||||||
>
|
>
|
||||||
{option.label}
|
{copy[option.labelKey]}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import { getGlobalModelOptions } from '@/hermes'
|
import { getGlobalModelOptions } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
@ -50,6 +51,8 @@ interface ProviderGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.shell.modelMenu
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
// Reactive session state is read from the stores here (not drilled in), so
|
// Reactive session state is read from the stores here (not drilled in), so
|
||||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||||
@ -95,9 +98,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSearch
|
<DropdownMenuSearch
|
||||||
aria-label="Search models"
|
aria-label={copy.search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder="Search models"
|
placeholder={copy.search}
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -122,7 +125,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||||
No models found
|
{copy.noModels}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||||
@ -158,13 +161,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
// others show a fast-capability hint.
|
// others show a fast-capability hint.
|
||||||
const meta = isCurrent
|
const meta = isCurrent
|
||||||
? [
|
? [
|
||||||
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
|
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||||
reasoningEffortLabel(currentReasoningEffort) || 'Med'
|
reasoningEffortLabel(currentReasoningEffort) || copy.medium
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
: caps?.fast || family.fastId
|
: caps?.fast || family.fastId
|
||||||
? 'Fast'
|
? copy.fast
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
// Every row is a hover-Edit submenu trigger. Activating it
|
// Every row is a hover-Edit submenu trigger. Activating it
|
||||||
@ -218,7 +221,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||||
onSelect={() => setModelVisibilityOpen(true)}
|
onSelect={() => setModelVisibilityOpen(true)}
|
||||||
>
|
>
|
||||||
Edit Models…
|
{copy.editModels}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -143,7 +143,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
aria-label="Window controls"
|
aria-label={t.shell.windowControls}
|
||||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||||
>
|
>
|
||||||
{leftToolbarTools
|
{leftToolbarTools
|
||||||
@ -163,7 +163,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
*/}
|
*/}
|
||||||
{visiblePaneTools.length > 0 && (
|
{visiblePaneTools.length > 0 && (
|
||||||
<div
|
<div
|
||||||
aria-label="Pane controls"
|
aria-label={t.shell.paneControls}
|
||||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||||
>
|
>
|
||||||
{visiblePaneTools.map(tool => (
|
{visiblePaneTools.map(tool => (
|
||||||
@ -173,7 +173,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
aria-label="App controls"
|
aria-label={t.shell.appControls}
|
||||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||||
>
|
>
|
||||||
{visibleSystemToolsBeforeSettings.map(tool => (
|
{visibleSystemToolsBeforeSettings.map(tool => (
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { writeClipboardText } from '@/components/ui/copy-button'
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { ErrorState } from '@/components/ui/error-state'
|
import { ErrorState } from '@/components/ui/error-state'
|
||||||
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||||
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
|
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -21,17 +22,6 @@ import {
|
|||||||
type UpdateApplyState
|
type UpdateApplyState
|
||||||
} from '@/store/updates'
|
} from '@/store/updates'
|
||||||
|
|
||||||
const STAGE_LABELS: Record<DesktopUpdateStage, string> = {
|
|
||||||
idle: 'Getting ready…',
|
|
||||||
prepare: 'Getting ready…',
|
|
||||||
fetch: 'Downloading…',
|
|
||||||
pull: 'Almost there…',
|
|
||||||
pydeps: 'Finishing up…',
|
|
||||||
restart: 'Restarting Hermes…',
|
|
||||||
manual: 'Update from your terminal',
|
|
||||||
error: 'Update paused'
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalItems(groups: readonly CommitGroup[]) {
|
function totalItems(groups: readonly CommitGroup[]) {
|
||||||
return groups.reduce((sum, g) => sum + g.items.length, 0)
|
return groups.reduce((sum, g) => sum + g.items.length, 0)
|
||||||
}
|
}
|
||||||
@ -124,9 +114,12 @@ function IdleView({
|
|||||||
onRetryCheck: () => void
|
onRetryCheck: () => void
|
||||||
status: DesktopUpdateStatus | null
|
status: DesktopUpdateStatus | null
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const u = t.updates
|
||||||
|
|
||||||
if (!status && checking) {
|
if (!status && checking) {
|
||||||
return (
|
return (
|
||||||
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
|
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={u.checking} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,11 +128,11 @@ function IdleView({
|
|||||||
<CenteredStatus
|
<CenteredStatus
|
||||||
action={
|
action={
|
||||||
<Button onClick={onRetryCheck} size="sm">
|
<Button onClick={onRetryCheck} size="sm">
|
||||||
Try again
|
{u.tryAgain}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||||
title="Couldn’t check for updates"
|
title={u.checkFailedTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -147,9 +140,9 @@ function IdleView({
|
|||||||
if (!status.supported) {
|
if (!status.supported) {
|
||||||
return (
|
return (
|
||||||
<CenteredStatus
|
<CenteredStatus
|
||||||
body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'}
|
body={status.message ?? u.unsupportedMessage}
|
||||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||||
title="Update not available"
|
title={u.notAvailableTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -159,12 +152,12 @@ function IdleView({
|
|||||||
<CenteredStatus
|
<CenteredStatus
|
||||||
action={
|
action={
|
||||||
<Button disabled={checking} onClick={onRetryCheck} size="sm">
|
<Button disabled={checking} onClick={onRetryCheck} size="sm">
|
||||||
Try again
|
{u.tryAgain}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
body="Check your connection and try again."
|
body={u.connectionRetry}
|
||||||
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
icon={<AlertCircle className="size-6 text-muted-foreground" />}
|
||||||
title="Couldn’t check for updates"
|
title={u.checkFailedTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -172,9 +165,9 @@ function IdleView({
|
|||||||
if (behind === 0) {
|
if (behind === 0) {
|
||||||
return (
|
return (
|
||||||
<CenteredStatus
|
<CenteredStatus
|
||||||
body="You’re running the latest version."
|
body={u.latestBody}
|
||||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||||
title="You’re all set"
|
title={u.allSetTitle}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -190,9 +183,9 @@ function IdleView({
|
|||||||
<Sparkles className="size-7" />
|
<Sparkles className="size-7" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DialogTitle className="text-center text-xl">New update available</DialogTitle>
|
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
|
||||||
<DialogDescription className="text-center text-sm">
|
<DialogDescription className="text-center text-sm">
|
||||||
A new version of Hermes is ready to install.
|
{u.availableBody}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -214,20 +207,20 @@ function IdleView({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Button className="font-semibold" onClick={onInstall} size="lg">
|
<Button className="font-semibold" onClick={onInstall} size="lg">
|
||||||
Update now
|
{u.updateNow}
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
onClick={onLater}
|
onClick={onLater}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Maybe later
|
{u.maybeLater}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{remaining > 0 && (
|
{remaining > 0 && (
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
+ {remaining} more change{remaining === 1 ? '' : 's'} included.
|
{u.moreChanges(remaining)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -235,6 +228,8 @@ function IdleView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
|
function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const u = t.updates
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
@ -251,9 +246,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
|||||||
<Terminal className="size-7" />
|
<Terminal className="size-7" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DialogTitle className="text-center text-xl">Update from your terminal</DialogTitle>
|
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
|
||||||
<DialogDescription className="text-center text-sm">
|
<DialogDescription className="text-center text-sm">
|
||||||
You installed Hermes from the command line, so updates run there too. Paste this into your terminal:
|
{u.manualBody}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -270,30 +265,32 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
|||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||||
Copied
|
{u.copied}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
Copy
|
{u.copy}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Hermes will pick up the new version next time you launch it.
|
{u.manualPickedUp}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
|
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
|
||||||
Done
|
{u.done}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||||
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
|
const { t } = useI18n()
|
||||||
|
const u = t.updates
|
||||||
|
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||||
|
|
||||||
const percent =
|
const percent =
|
||||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||||
@ -309,7 +306,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
|||||||
|
|
||||||
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
||||||
<DialogDescription className="text-center text-sm">
|
<DialogDescription className="text-center text-sm">
|
||||||
The Hermes updater will take over in its own window and reopen Hermes when it’s done.
|
{u.applyingBody}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -323,29 +320,32 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">Hermes will close to apply the update.</p>
|
<p className="text-center text-xs text-muted-foreground">{u.applyingClose}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
|
function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const u = t.updates
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
className="px-6 pb-6 pt-7 pr-8"
|
className="px-6 pb-6 pt-7 pr-8"
|
||||||
description={
|
description={
|
||||||
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
|
||||||
{message || 'No worries — nothing was lost. You can try again now.'}
|
{message || u.errorBody}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didn’t finish</DialogTitle>
|
<DialogTitle className="text-center text-xl font-semibold tracking-tight">{u.errorTitle}</DialogTitle>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button className="font-semibold" onClick={onRetry} size="lg">
|
<Button className="font-semibold" onClick={onRetry} size="lg">
|
||||||
Try again
|
{u.tryAgain}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onDismiss} variant="text">
|
<Button onClick={onDismiss} variant="text">
|
||||||
Not now
|
{u.notNow}
|
||||||
</Button>
|
</Button>
|
||||||
</ErrorState>
|
</ErrorState>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useSt
|
|||||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
|
import { Check, HelpCircle, Loader2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@ -63,6 +64,8 @@ export const ClarifyTool = (props: ToolCallMessagePartProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.clarify
|
||||||
const request = useStore($clarifyRequest)
|
const request = useStore($clarifyRequest)
|
||||||
const gateway = useStore($gateway)
|
const gateway = useStore($gateway)
|
||||||
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
const fromArgs = useMemo(() => readClarifyArgs(args), [args])
|
||||||
@ -102,13 +105,13 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
const respond = useCallback(
|
const respond = useCallback(
|
||||||
async (answer: string) => {
|
async (answer: string) => {
|
||||||
if (!ready || !matchingRequest) {
|
if (!ready || !matchingRequest) {
|
||||||
notifyError(new Error('Clarify request is not ready yet'), 'Could not send clarify response')
|
notifyError(new Error(copy.notReady), copy.sendFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send clarify response')
|
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -125,7 +128,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
// The matching tool.complete will land shortly after, swapping this
|
// The matching tool.complete will land shortly after, swapping this
|
||||||
// panel for the ToolFallback view above.
|
// panel for the ToolFallback view above.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Could not send clarify response')
|
notifyError(error, copy.sendFailed)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -172,7 +175,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
<HelpCircle className="size-3.5" />
|
<HelpCircle className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||||
{question || <em className="font-normal text-muted-foreground/70">Loading question…</em>}
|
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -209,7 +212,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RadioDot selected={false} />
|
<RadioDot selected={false} />
|
||||||
<span className="flex-1">Other (type your answer)</span>
|
<span className="flex-1">{copy.other}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -221,12 +224,12 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onChange={event => setDraft(event.target.value)}
|
onChange={event => setDraft(event.target.value)}
|
||||||
onKeyDown={handleTextareaKey}
|
onKeyDown={handleTextareaKey}
|
||||||
placeholder="Type your answer…"
|
placeholder={copy.placeholder}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[0.6875rem] text-muted-foreground/85">⌘/Ctrl + Enter to send</span>
|
<span className="text-[0.6875rem] text-muted-foreground/85">{copy.shortcut}</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{hasChoices && (
|
{hasChoices && (
|
||||||
<Button
|
<Button
|
||||||
@ -239,7 +242,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Back
|
{copy.back}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@ -249,10 +252,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
Skip
|
{copy.skip}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -75,6 +75,7 @@ import {
|
|||||||
import { Loader } from '@/components/ui/loader'
|
import { Loader } from '@/components/ui/loader'
|
||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||||
@ -183,22 +184,26 @@ function pickPrimaryPreviewTarget(targets: string[]): string[] {
|
|||||||
return [localUrl || targets[targets.length - 1]]
|
return [localUrl || targets[targets.length - 1]]
|
||||||
}
|
}
|
||||||
|
|
||||||
const CenteredThreadSpinner: FC = () => (
|
const CenteredThreadSpinner: FC = () => {
|
||||||
<div
|
const { t } = useI18n()
|
||||||
aria-label="Loading session"
|
|
||||||
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
return (
|
||||||
role="status"
|
<div
|
||||||
>
|
aria-label={t.assistant.thread.loadingSession}
|
||||||
<Loader
|
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
||||||
aria-hidden="true"
|
role="status"
|
||||||
className="size-12 text-midground/70"
|
>
|
||||||
pathSteps={220}
|
<Loader
|
||||||
role="presentation"
|
aria-hidden="true"
|
||||||
strokeScale={0.72}
|
className="size-12 text-midground/70"
|
||||||
type="rose-curve"
|
pathSteps={220}
|
||||||
/>
|
role="presentation"
|
||||||
</div>
|
strokeScale={0.72}
|
||||||
)
|
type="rose-curve"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
@ -277,10 +282,11 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
|||||||
)
|
)
|
||||||
|
|
||||||
const ResponseLoadingIndicator: FC = () => {
|
const ResponseLoadingIndicator: FC = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
const elapsed = useElapsedSeconds()
|
const elapsed = useElapsedSeconds()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusRow data-slot="aui_response-loading" label="Hermes is loading a response">
|
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
|
||||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||||
<ActivityTimerText seconds={elapsed} />
|
<ActivityTimerText seconds={elapsed} />
|
||||||
</StatusRow>
|
</StatusRow>
|
||||||
@ -329,6 +335,7 @@ const ThinkingDisclosure: FC<{
|
|||||||
pending?: boolean
|
pending?: boolean
|
||||||
timerKey?: string
|
timerKey?: string
|
||||||
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
}> = ({ children, messageRunning = false, pending = false, timerKey }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
// `null` = no explicit user toggle yet, defer to the streaming default.
|
// `null` = no explicit user toggle yet, defer to the streaming default.
|
||||||
// The default is "auto-open while streaming, auto-collapse when done" so
|
// The default is "auto-open while streaming, auto-collapse when done" so
|
||||||
// reasoning surfaces a live preview without manual interaction. The first
|
// reasoning surfaces a live preview without manual interaction. The first
|
||||||
@ -385,7 +392,7 @@ const ThinkingDisclosure: FC<{
|
|||||||
pending && 'shimmer text-foreground/55'
|
pending && 'shimmer text-foreground/55'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Thinking
|
{t.assistant.thread.thinking}
|
||||||
</span>
|
</span>
|
||||||
{pending && (
|
{pending && (
|
||||||
<ActivityTimerText
|
<ActivityTimerText
|
||||||
@ -487,7 +494,10 @@ function startOfDay(d: Date): number {
|
|||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMessageTimestamp(value: Date | string | number | undefined): string {
|
function formatMessageTimestamp(
|
||||||
|
value: Date | string | number | undefined,
|
||||||
|
labels: { today: (time: string) => string; yesterday: (time: string) => string }
|
||||||
|
): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@ -501,17 +511,19 @@ function formatMessageTimestamp(value: Date | string | number | undefined): stri
|
|||||||
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
||||||
|
|
||||||
if (dayDelta === 0) {
|
if (dayDelta === 0) {
|
||||||
return `Today, ${TIME_FMT.format(date)}`
|
return labels.today(TIME_FMT.format(date))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dayDelta === 1) {
|
if (dayDelta === 1) {
|
||||||
return `Yesterday, ${TIME_FMT.format(date)}`
|
return labels.yesterday(TIME_FMT.format(date))
|
||||||
}
|
}
|
||||||
|
|
||||||
return SHORT_FMT.format(date)
|
return SHORT_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.thread
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -530,15 +542,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||||||
)}
|
)}
|
||||||
data-slot="aui_msg-actions"
|
data-slot="aui_msg-actions"
|
||||||
>
|
>
|
||||||
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label="Copy" text={messageText} />
|
<CopyButton appearance="icon" buttonSize="icon" disabled={!messageText} label={copy.copy} text={messageText} />
|
||||||
<ActionBarPrimitive.Reload asChild>
|
<ActionBarPrimitive.Reload asChild>
|
||||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
|
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
|
||||||
<Codicon name="refresh" />
|
<Codicon name="refresh" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</ActionBarPrimitive.Reload>
|
</ActionBarPrimitive.Reload>
|
||||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipIconButton tooltip="More actions">
|
<TooltipIconButton tooltip={copy.moreActions}>
|
||||||
<Codicon name="ellipsis" />
|
<Codicon name="ellipsis" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@ -546,7 +558,7 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||||||
<MessageTimestamp />
|
<MessageTimestamp />
|
||||||
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
||||||
<GitBranchIcon />
|
<GitBranchIcon />
|
||||||
Branch in new chat
|
{copy.branchNewChat}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<ReadAloudItem messageId={messageId} text={messageText} />
|
<ReadAloudItem messageId={messageId} text={messageText} />
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@ -557,6 +569,8 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.thread
|
||||||
const voicePlayback = useStore($voicePlayback)
|
const voicePlayback = useStore($voicePlayback)
|
||||||
|
|
||||||
const readAloudStatus =
|
const readAloudStatus =
|
||||||
@ -575,9 +589,9 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
|||||||
try {
|
try {
|
||||||
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
await playSpeechText(text, { messageId, source: 'read-aloud' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Read aloud failed')
|
notifyError(error, copy.readAloudFailed)
|
||||||
}
|
}
|
||||||
}, [messageId, text])
|
}, [copy.readAloudFailed, messageId, text])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -588,14 +602,15 @@ const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, tex
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
||||||
{isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
|
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageTimestamp: FC = () => {
|
const MessageTimestamp: FC = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
const createdAt = useAuiState(s => s.message.createdAt)
|
const createdAt = useAuiState(s => s.message.createdAt)
|
||||||
const label = formatMessageTimestamp(createdAt)
|
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
|
||||||
|
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return null
|
return null
|
||||||
@ -662,6 +677,8 @@ const StopGlyph = <IconPlayerStopFilled aria-hidden className="size-3.5 -transla
|
|||||||
const UserMessage: FC<{
|
const UserMessage: FC<{
|
||||||
onCancel?: () => Promise<void> | void
|
onCancel?: () => Promise<void> | void
|
||||||
}> = ({ onCancel }) => {
|
}> = ({ onCancel }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.thread
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
const content = useAuiState(s => s.message.content)
|
const content = useAuiState(s => s.message.content)
|
||||||
const messageText = messageContentText(content)
|
const messageText = messageContentText(content)
|
||||||
@ -753,10 +770,10 @@ const UserMessage: FC<{
|
|||||||
) : (
|
) : (
|
||||||
<ActionBarPrimitive.Edit asChild>
|
<ActionBarPrimitive.Edit asChild>
|
||||||
<button
|
<button
|
||||||
aria-label="Edit message"
|
aria-label={copy.editMessage}
|
||||||
className={bubbleClassName}
|
className={bubbleClassName}
|
||||||
onClick={() => triggerHaptic('selection')}
|
onClick={() => triggerHaptic('selection')}
|
||||||
title="Edit message"
|
title={copy.editMessage}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{bubbleContent}
|
{bubbleContent}
|
||||||
@ -767,14 +784,14 @@ const UserMessage: FC<{
|
|||||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||||
{showStop ? (
|
{showStop ? (
|
||||||
<button
|
<button
|
||||||
aria-label="Stop"
|
aria-label={copy.stop}
|
||||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
void onCancel?.()
|
void onCancel?.()
|
||||||
}}
|
}}
|
||||||
title="Stop"
|
title={copy.stop}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{StopGlyph}
|
{StopGlyph}
|
||||||
@ -783,7 +800,7 @@ const UserMessage: FC<{
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
className="flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary)"
|
||||||
title="Editable checkpoint"
|
title={copy.editableCheckpoint}
|
||||||
>
|
>
|
||||||
<Codicon name="discard" size="0.875rem" />
|
<Codicon name="discard" size="0.875rem" />
|
||||||
</span>
|
</span>
|
||||||
@ -798,18 +815,18 @@ const UserMessage: FC<{
|
|||||||
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
<span aria-hidden className="checkpoint-icon size-1.5 rounded-full border border-current" />
|
||||||
<BranchPickerPrimitive.Previous
|
<BranchPickerPrimitive.Previous
|
||||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||||
title="Restore previous checkpoint"
|
title={copy.restorePrevious}
|
||||||
>
|
>
|
||||||
Restore checkpoint
|
{copy.restoreCheckpoint}
|
||||||
</BranchPickerPrimitive.Previous>
|
</BranchPickerPrimitive.Previous>
|
||||||
<span className="checkpoint-divider opacity-55">
|
<span className="checkpoint-divider opacity-55">
|
||||||
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
<BranchPickerPrimitive.Number />/<BranchPickerPrimitive.Count />
|
||||||
</span>
|
</span>
|
||||||
<BranchPickerPrimitive.Next
|
<BranchPickerPrimitive.Next
|
||||||
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
className="checkpoint-restore-text rounded-sm bg-transparent px-1 opacity-65 hover:opacity-100 disabled:hidden disabled:cursor-default"
|
||||||
title="Restore next checkpoint"
|
title={copy.restoreNext}
|
||||||
>
|
>
|
||||||
Go forward
|
{copy.goForward}
|
||||||
</BranchPickerPrimitive.Next>
|
</BranchPickerPrimitive.Next>
|
||||||
</BranchPickerPrimitive.Root>
|
</BranchPickerPrimitive.Root>
|
||||||
</div>
|
</div>
|
||||||
@ -880,6 +897,8 @@ interface UserEditComposerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.thread
|
||||||
const aui = useAui()
|
const aui = useAui()
|
||||||
const draft = useAuiState(s => s.composer.text)
|
const draft = useAuiState(s => s.composer.text)
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
@ -1356,7 +1375,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
data-expanded={expanded ? 'true' : undefined}
|
data-expanded={expanded ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-label="Edit message"
|
aria-label={copy.editMessage}
|
||||||
autoFocus
|
autoFocus
|
||||||
className={cn(
|
className={cn(
|
||||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||||
@ -1365,7 +1384,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||||
)}
|
)}
|
||||||
contentEditable
|
contentEditable
|
||||||
data-placeholder="Edit message"
|
data-placeholder={copy.editMessage}
|
||||||
data-slot={RICH_INPUT_SLOT}
|
data-slot={RICH_INPUT_SLOT}
|
||||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@ -1382,7 +1401,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
/>
|
/>
|
||||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||||
<button
|
<button
|
||||||
aria-label="Send edited message"
|
aria-label={copy.sendEdited}
|
||||||
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
className={cn('absolute right-2 bottom-2 size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||||
disabled={!canSubmit || submitting}
|
disabled={!canSubmit || submitting}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -1392,7 +1411,7 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
|||||||
submitEdit(editor)
|
submitEdit(editor)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Send edited message"
|
title={copy.sendEdited}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||||
import { $gateway } from '@/store/gateway'
|
import { $gateway } from '@/store/gateway'
|
||||||
@ -52,6 +53,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
|||||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||||
|
|
||||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.approval
|
||||||
const gateway = useStore($gateway)
|
const gateway = useStore($gateway)
|
||||||
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
|
const [submitting, setSubmitting] = useState<ApprovalChoice | null>(null)
|
||||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||||
@ -68,7 +71,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send approval response')
|
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -83,7 +86,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
|
triggerHaptic(choice === 'deny' ? 'cancel' : 'submit')
|
||||||
clearApprovalRequest(request.sessionId)
|
clearApprovalRequest(request.sessionId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Could not send approval response')
|
notifyError(error, copy.sendFailed)
|
||||||
setSubmitting(null)
|
setSubmitting(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -123,14 +126,14 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : 'Run'}
|
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
aria-label="More approval options"
|
aria-label={copy.moreOptions}
|
||||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
size="xs"
|
size="xs"
|
||||||
@ -140,7 +143,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="min-w-44">
|
<DropdownMenuContent align="start" className="min-w-44">
|
||||||
<DropdownMenuItem onSelect={() => void respond('session')}>Allow this session</DropdownMenuItem>
|
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
// Defer one tick so the menu fully unmounts before the dialog
|
// Defer one tick so the menu fully unmounts before the dialog
|
||||||
@ -149,10 +152,10 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
setTimeout(() => setConfirmAlways(true), 0)
|
setTimeout(() => setConfirmAlways(true), 0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Always allow…
|
{copy.alwaysAllowMenu}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||||
Reject
|
{copy.reject}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -165,18 +168,16 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : 'Reject'}
|
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Always allow this command?</DialogTitle>
|
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This adds the “{request.description}” pattern to your permanent allowlist (
|
{copy.alwaysDescription(request.description)}
|
||||||
<code className="font-mono text-xs">~/.hermes/config.yaml</code>). Hermes won’t ask again for commands
|
|
||||||
like this — in this session or any future one.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@ -188,7 +189,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
|
<Button onClick={() => setConfirmAlways(false)} size="sm" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -198,7 +199,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Always allow
|
{copy.alwaysAllow}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { normalizeExternalUrl } from '@/lib/external-link'
|
import { normalizeExternalUrl } from '@/lib/external-link'
|
||||||
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary'
|
||||||
|
import { translateNow } from '@/i18n'
|
||||||
|
|
||||||
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web'
|
||||||
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
export type ToolStatus = 'error' | 'running' | 'success' | 'warning'
|
||||||
@ -1081,6 +1082,17 @@ function toolDetailText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
|
export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string; text: string } {
|
||||||
|
const copy = {
|
||||||
|
command: translateNow('assistant.tool.copyCommand'),
|
||||||
|
content: translateNow('assistant.tool.copyContent'),
|
||||||
|
file: translateNow('assistant.tool.copyFile'),
|
||||||
|
output: translateNow('assistant.tool.copyOutput'),
|
||||||
|
path: translateNow('assistant.tool.copyPath'),
|
||||||
|
query: translateNow('assistant.tool.copyQuery'),
|
||||||
|
results: translateNow('assistant.tool.copyResults'),
|
||||||
|
url: translateNow('assistant.tool.copyUrl'),
|
||||||
|
generic: translateNow('common.copy')
|
||||||
|
}
|
||||||
const args = parseMaybeObject(part.args)
|
const args = parseMaybeObject(part.args)
|
||||||
const result = parseMaybeObject(part.result)
|
const result = parseMaybeObject(part.result)
|
||||||
const detail = view.detail.trim()
|
const detail = view.detail.trim()
|
||||||
@ -1088,25 +1100,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||||||
|
|
||||||
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
if (part.toolName === 'terminal' || part.toolName === 'execute_code') {
|
||||||
if (hasSubstantialOutput) {
|
if (hasSubstantialOutput) {
|
||||||
return { label: 'Copy output', text: detail }
|
return { label: copy.output, text: detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
|
const command = firstStringField(args, ['command', 'code']) || contextValue(args)
|
||||||
|
|
||||||
if (command) {
|
if (command) {
|
||||||
return { label: 'Copy command', text: command }
|
return { label: copy.command, text: command }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.toolName === 'web_extract') {
|
if (part.toolName === 'web_extract') {
|
||||||
if (hasSubstantialOutput) {
|
if (hasSubstantialOutput) {
|
||||||
return { label: 'Copy content', text: detail }
|
return { label: copy.content, text: detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
|
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
return { label: 'Copy URL', text: url }
|
return { label: copy.url, text: url }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1114,7 +1126,7 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||||||
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
|
const url = firstStringField(args, ['url', 'target']) || findFirstUrl(args, result)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
return { label: 'Copy URL', text: url }
|
return { label: copy.url, text: url }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1122,25 +1134,25 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||||||
if (view.searchHits?.length) {
|
if (view.searchHits?.length) {
|
||||||
const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
|
const text = view.searchHits.map(hit => [hit.title, hit.url, hit.snippet].filter(Boolean).join('\n')).join('\n\n')
|
||||||
|
|
||||||
return { label: 'Copy results', text }
|
return { label: copy.results, text }
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
|
const query = firstStringField(args, ['search_term', 'query']) || contextValue(args)
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
return { label: 'Copy query', text: query }
|
return { label: copy.query, text: query }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.toolName === 'read_file') {
|
if (part.toolName === 'read_file') {
|
||||||
if (hasSubstantialOutput) {
|
if (hasSubstantialOutput) {
|
||||||
return { label: 'Copy file', text: detail }
|
return { label: copy.file, text: detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
return { label: 'Copy path', text: path }
|
return { label: copy.path, text: path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1148,15 +1160,15 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string
|
|||||||
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
const path = firstStringField(args, ['path', 'file', 'filepath'])
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
return { label: 'Copy path', text: path }
|
return { label: copy.path, text: path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detail) {
|
if (detail) {
|
||||||
return { label: 'Copy output', text: detail }
|
return { label: copy.output, text: detail }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { label: 'Copy', text: view.title }
|
return { label: copy.generic, text: view.title }
|
||||||
}
|
}
|
||||||
|
|
||||||
function dynamicTitle(
|
function dynamicTitle(
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
import { FadeText } from '@/components/ui/fade-text'
|
import { FadeText } from '@/components/ui/fade-text'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
||||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
@ -188,6 +189,8 @@ function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolEntry({ part }: ToolEntryProps) {
|
function ToolEntry({ part }: ToolEntryProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.tool
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
const messageRunning = useAuiState(selectMessageRunning)
|
const messageRunning = useAuiState(selectMessageRunning)
|
||||||
const embedded = useContext(ToolEmbedContext)
|
const embedded = useContext(ToolEmbedContext)
|
||||||
@ -319,7 +322,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||||||
)}
|
)}
|
||||||
{view.imageUrl && (
|
{view.imageUrl && (
|
||||||
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
||||||
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
|
<ZoomableImage alt={copy.outputAlt} className="h-auto w-full object-cover" src={view.imageUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasSearchHits && view.searchHits && (
|
{hasSearchHits && view.searchHits && (
|
||||||
@ -390,7 +393,7 @@ function ToolEntry({ part }: ToolEntryProps) {
|
|||||||
))}
|
))}
|
||||||
{showRawSearchDrilldown && (
|
{showRawSearchDrilldown && (
|
||||||
<details className="max-w-full">
|
<details className="max-w-full">
|
||||||
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>Raw response</summary>
|
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'mb-0')}>{copy.rawResponse}</summary>
|
||||||
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
||||||
{view.rawResult}
|
{view.rawResult}
|
||||||
</pre>
|
</pre>
|
||||||
@ -432,6 +435,8 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||||||
endIndex,
|
endIndex,
|
||||||
startIndex
|
startIndex
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.assistant.tool
|
||||||
const messageId = useAuiState(s => s.message.id)
|
const messageId = useAuiState(s => s.message.id)
|
||||||
const messageRunning = useAuiState(selectMessageRunning)
|
const messageRunning = useAuiState(selectMessageRunning)
|
||||||
|
|
||||||
@ -489,11 +494,11 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||||||
? ''
|
? ''
|
||||||
: displayStatus === 'warning'
|
: displayStatus === 'warning'
|
||||||
? failedStepCount === 1
|
? failedStepCount === 1
|
||||||
? 'Recovered after 1 failed step'
|
? copy.recoveredOne
|
||||||
: `Recovered after ${failedStepCount} failed steps`
|
: copy.recoveredMany(failedStepCount)
|
||||||
: failedStepCount === 1
|
: failedStepCount === 1
|
||||||
? '1 step failed'
|
? copy.failedOne
|
||||||
: `${failedStepCount} steps failed`
|
: copy.failedMany(failedStepCount)
|
||||||
|
|
||||||
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
|
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
|
||||||
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
|
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
|
||||||
@ -508,7 +513,7 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
|||||||
open={open}
|
open={open}
|
||||||
trailing={
|
trailing={
|
||||||
!isRunning && groupCopyText ? (
|
!isRunning && groupCopyText ? (
|
||||||
<CopyButton appearance="tool-row" label="Copy activity" stopPropagation text={groupCopyText} />
|
<CopyButton appearance="tool-row" label={copy.copyActivity} stopPropagation text={groupCopyText} />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { type FC, useCallback, useEffect, useRef } from 'react'
|
import { type FC, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
type Rgb = { r: number; g: number; b: number }
|
type Rgb = { r: number; g: number; b: number }
|
||||||
|
|
||||||
@ -266,8 +267,10 @@ const DiffusionCanvas: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ImageGenerationPlaceholder: FC = () => {
|
export const ImageGenerationPlaceholder: FC = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status">
|
<div aria-label={t.assistant.tool.renderingImage} aria-live="polite" className="w-full max-w-136 self-start" role="status">
|
||||||
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
|
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
|
||||||
<DiffusionCanvas />
|
<DiffusionCanvas />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { MonitorPlay } from '@/lib/icons'
|
import { MonitorPlay } from '@/lib/icons'
|
||||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||||
import { previewName } from '@/lib/preview-targets'
|
import { previewName } from '@/lib/preview-targets'
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
import { $currentCwd } from '@/store/session'
|
import { $currentCwd } from '@/store/session'
|
||||||
|
|
||||||
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const cwd = useStore($currentCwd)
|
const cwd = useStore($currentCwd)
|
||||||
const activePreview = useStore($previewTarget)
|
const activePreview = useStore($previewTarget)
|
||||||
const [opening, setOpening] = useState(false)
|
const [opening, setOpening] = useState(false)
|
||||||
@ -93,7 +95,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyError(error, 'Preview unavailable')
|
notifyError(error, t.preview.unavailable)
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current && requestTokenRef.current === requestToken) {
|
if (mountedRef.current && requestTokenRef.current === requestToken) {
|
||||||
setOpening(false)
|
setOpening(false)
|
||||||
@ -116,7 +118,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
|||||||
onClick={() => void togglePreview()}
|
onClick={() => void togglePreview()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
|
{opening ? t.preview.opening : isActive ? t.preview.hide : t.preview.openPreview}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
CodeCardTitle
|
CodeCardTitle
|
||||||
} from '@/components/chat/code-card'
|
} from '@/components/chat/code-card'
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
import { CopyButton } from '@/components/ui/copy-button'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,6 +49,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
code,
|
code,
|
||||||
defer = false
|
defer = false
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useI18n()
|
||||||
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
|
const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||||
|
|
||||||
// Streaming may hand us empty/incomplete fences — render nothing rather
|
// Streaming may hand us empty/incomplete fences — render nothing rather
|
||||||
@ -68,14 +70,14 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
|||||||
<CodeCardHeader>
|
<CodeCardHeader>
|
||||||
<CodeCardTitle>
|
<CodeCardTitle>
|
||||||
<CodeCardIcon name={codiconForLanguage(label)} />
|
<CodeCardIcon name={codiconForLanguage(label)} />
|
||||||
Code
|
{t.assistant.tool.code}
|
||||||
{label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>}
|
{label && <CodeCardSubtitle> · {label}</CodeCardSubtitle>}
|
||||||
</CodeCardTitle>
|
</CodeCardTitle>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100"
|
className="-my-1 -mr-1 h-5 px-1 opacity-55 hover:opacity-100"
|
||||||
iconClassName="size-2.5"
|
iconClassName="size-2.5"
|
||||||
label="Copy code"
|
label={t.assistant.tool.copyCode}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
text={trimmed}
|
text={trimmed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { type ComponentProps, useState } from 'react'
|
import { type ComponentProps, useState } from 'react'
|
||||||
|
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Download } from '@/lib/icons'
|
import { Download } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
@ -51,6 +52,8 @@ export interface ZoomableImageProps extends ComponentProps<'img'> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
export function ZoomableImage({ className, containerClassName, src, alt, slot, ...props }: ZoomableImageProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.desktop
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||||
const canOpen = Boolean(src)
|
const canOpen = Boolean(src)
|
||||||
@ -67,7 +70,7 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
|||||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||||
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
notify({ kind: 'success', title: 'Image saved', message: imageFilename(src) })
|
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@ -80,17 +83,17 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
|||||||
await startBrowserDownload(src)
|
await startBrowserDownload(src)
|
||||||
notify({
|
notify({
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
title: 'Download started',
|
title: copy.downloadStarted,
|
||||||
message: 'Restart Hermes Desktop to use Save Image.'
|
message: copy.restartToUseSaveImage
|
||||||
})
|
})
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
notifyError(fallbackError, copy.restartToSaveImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyError(error, 'Image download failed')
|
notifyError(error, copy.imageDownloadFailed)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
DesktopBootstrapStageState,
|
DesktopBootstrapStageState,
|
||||||
DesktopBootstrapState
|
DesktopBootstrapState
|
||||||
} from '@/global'
|
} from '@/global'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
|
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@ -49,14 +50,6 @@ interface StageRowProps {
|
|||||||
now: number
|
now: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
|
|
||||||
pending: 'Pending',
|
|
||||||
running: 'Installing',
|
|
||||||
succeeded: 'Done',
|
|
||||||
skipped: 'Skipped',
|
|
||||||
failed: 'Failed'
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStageName(name: string): string {
|
function formatStageName(name: string): string {
|
||||||
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
|
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
|
||||||
if (name.length <= 3) {
|
if (name.length <= 3) {
|
||||||
@ -104,6 +97,8 @@ function formatElapsed(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.install
|
||||||
const state: DesktopBootstrapStageState = result?.state || 'pending'
|
const state: DesktopBootstrapStageState = result?.state || 'pending'
|
||||||
|
|
||||||
const elapsed =
|
const elapsed =
|
||||||
@ -147,9 +142,13 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
|||||||
{formatStageName(descriptor.name)}
|
{formatStageName(descriptor.name)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||||
{state === 'running' ? (elapsed ? `${STATE_LABEL[state]} · ${elapsed}` : STATE_LABEL[state]) : null}
|
{state === 'running'
|
||||||
|
? elapsed
|
||||||
|
? `${copy.stageStates[state]} · ${elapsed}`
|
||||||
|
: copy.stageStates[state]
|
||||||
|
: null}
|
||||||
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
|
{state === 'succeeded' || state === 'skipped' ? formatDuration(result?.durationMs) : null}
|
||||||
{state === 'failed' ? STATE_LABEL[state] : null}
|
{state === 'failed' ? copy.stageStates[state] : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
|
||||||
@ -242,6 +241,8 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
|
export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.install
|
||||||
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
|
||||||
const [logOpen, setLogOpen] = useState(false)
|
const [logOpen, setLogOpen] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@ -350,14 +351,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
||||||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
|
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
|
{copy.unsupportedDesc(platformLabel)}
|
||||||
command below, then relaunch this app. Subsequent launches will skip this step.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">Install command</div>
|
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{copy.installCommand}</div>
|
||||||
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
|
<pre className="overflow-x-auto rounded-md border bg-muted/50 px-3 py-2.5 font-mono text-[12px]">
|
||||||
<code>{ups.installCommand}</code>
|
<code>{ups.installCommand}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@ -369,7 +369,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
Copy command
|
{copy.copyCommand}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -378,17 +378,17 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
View install docs
|
{copy.viewDocs}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
<div className="mt-6 flex items-center justify-between border-t pt-4">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
{copy.installTo} <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
||||||
I{'\u2019'}ve run it -- retry
|
{copy.retryAfterRun}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -415,13 +415,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
{/* Header -- always visible, never scrolls */}
|
{/* Header -- always visible, never scrolls */}
|
||||||
<div className="flex-shrink-0 p-8 pb-4">
|
<div className="flex-shrink-0 p-8 pb-4">
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
{failed ? 'Installation failed' : state.active ? 'Setting up Hermes Agent' : 'Finishing up'}
|
{failed ? copy.failedTitle : state.active ? copy.settingUpTitle : copy.finishingTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-sm text-muted-foreground">
|
<p className="mt-1.5 text-sm text-muted-foreground">
|
||||||
{failed
|
{failed ? copy.failedDesc : copy.activeDesc}
|
||||||
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
|
|
||||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
|
|
||||||
'Subsequent launches will skip this step.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -431,8 +428,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{completedCount} of {totalCount} steps complete
|
{copy.progress(completedCount, totalCount)}
|
||||||
{currentStage && ` -- now: ${formatStageName(currentStage)}`}
|
{currentStage && copy.currentStage(formatStageName(currentStage))}
|
||||||
{currentElapsed && ` (${currentElapsed})`}
|
{currentElapsed && ` (${currentElapsed})`}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">{progressPct}%</span>
|
<span className="tabular-nums">{progressPct}%</span>
|
||||||
@ -449,7 +446,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
{totalCount === 0 && state.active && (
|
{totalCount === 0 && state.active && (
|
||||||
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<span>Fetching installer manifest...</span>
|
<span>{copy.fetchingManifest}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -457,7 +454,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
|
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm">
|
||||||
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
|
<div className="mb-1 flex items-center gap-1.5 font-medium text-destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>Error</span>
|
<span>{copy.error}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
|
<p className="whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -484,9 +481,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||||
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
|
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
|
||||||
<span className="ml-1 tabular-nums">
|
<span className="ml-1 tabular-nums">
|
||||||
({state.log.length} line{state.log.length === 1 ? '' : 's'})
|
({copy.lines(state.log.length)})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -498,7 +495,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{state.log.length === 0 ? (
|
{state.log.length === 0 ? (
|
||||||
<div className="text-muted-foreground">No output yet.</div>
|
<div className="text-muted-foreground">{copy.noOutput}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{state.log.map((entry, i) => (
|
{state.log.map((entry, i) => (
|
||||||
@ -540,7 +537,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
{cancelling ? 'Cancelling...' : 'Cancel install'}
|
{cancelling ? copy.cancelling : copy.cancelInstall}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -551,7 +548,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
<div className="flex-shrink-0 border-t bg-card p-4">
|
<div className="flex-shrink-0 border-t bg-card p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Full transcript saved to{' '}
|
{copy.transcriptSaved}{' '}
|
||||||
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -574,7 +571,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{copied ? 'Copied!' : 'Copy output'}
|
{copied ? copy.copiedOutput : copy.copyOutput}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@ -593,7 +590,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
Reload and retry
|
{copy.reloadRetry}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { getGlobalModelOptions } from '@/hermes'
|
import { getGlobalModelOptions } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@ -51,7 +52,7 @@ interface DesktopOnboardingOverlayProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiKeyOption {
|
export interface ApiKeyOption {
|
||||||
description: string
|
description?: string
|
||||||
docsUrl: string
|
docsUrl: string
|
||||||
envKey: string
|
envKey: string
|
||||||
id: string
|
id: string
|
||||||
@ -64,41 +65,31 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
|
|||||||
{
|
{
|
||||||
id: 'openrouter',
|
id: 'openrouter',
|
||||||
name: 'OpenRouter',
|
name: 'OpenRouter',
|
||||||
short: 'one key, many models',
|
|
||||||
envKey: 'OPENROUTER_API_KEY',
|
envKey: 'OPENROUTER_API_KEY',
|
||||||
description: 'Hosts hundreds of models behind a single key. Good default for new installs.',
|
|
||||||
docsUrl: 'https://openrouter.ai/keys'
|
docsUrl: 'https://openrouter.ai/keys'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'openai',
|
id: 'openai',
|
||||||
name: 'OpenAI',
|
name: 'OpenAI',
|
||||||
short: 'GPT-class models',
|
|
||||||
envKey: 'OPENAI_API_KEY',
|
envKey: 'OPENAI_API_KEY',
|
||||||
description: 'Direct access to OpenAI models.',
|
|
||||||
docsUrl: 'https://platform.openai.com/api-keys'
|
docsUrl: 'https://platform.openai.com/api-keys'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini',
|
id: 'gemini',
|
||||||
name: 'Google Gemini',
|
name: 'Google Gemini',
|
||||||
short: 'Gemini models',
|
|
||||||
envKey: 'GEMINI_API_KEY',
|
envKey: 'GEMINI_API_KEY',
|
||||||
description: 'Direct access to Google Gemini models.',
|
|
||||||
docsUrl: 'https://aistudio.google.com/app/apikey'
|
docsUrl: 'https://aistudio.google.com/app/apikey'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'xai',
|
id: 'xai',
|
||||||
name: 'xAI Grok',
|
name: 'xAI Grok',
|
||||||
short: 'Grok models',
|
|
||||||
envKey: 'XAI_API_KEY',
|
envKey: 'XAI_API_KEY',
|
||||||
description: 'Direct access to xAI Grok models.',
|
|
||||||
docsUrl: 'https://console.x.ai/'
|
docsUrl: 'https://console.x.ai/'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'local',
|
id: 'local',
|
||||||
name: 'Local / custom endpoint',
|
name: 'Local / custom endpoint',
|
||||||
short: 'self-hosted',
|
|
||||||
envKey: 'OPENAI_BASE_URL',
|
envKey: 'OPENAI_BASE_URL',
|
||||||
description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).',
|
|
||||||
docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint',
|
docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint',
|
||||||
placeholder: 'http://127.0.0.1:8000/v1'
|
placeholder: 'http://127.0.0.1:8000/v1'
|
||||||
}
|
}
|
||||||
@ -118,13 +109,6 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
|||||||
|
|
||||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||||
|
|
||||||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
|
||||||
pkce: 'Opens your browser to sign in, then continues here',
|
|
||||||
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
|
||||||
loopback: 'Opens your browser to sign in — Hermes connects automatically',
|
|
||||||
external: 'Sign in once in your terminal, then come back to chat'
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||||
|
|
||||||
@ -242,6 +226,7 @@ function ReasonNotice({ reason }: { reason: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Preparing({ boot }: { boot: DesktopBootState }) {
|
function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
const progress = Math.max(2, Math.min(100, Math.round(boot.progress)))
|
||||||
const hasError = Boolean(boot.error)
|
const hasError = Boolean(boot.error)
|
||||||
const installing = boot.phase.startsWith('runtime.')
|
const installing = boot.phase.startsWith('runtime.')
|
||||||
@ -250,8 +235,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||||||
<div className="grid gap-3" role="status">
|
<div className="grid gap-3" role="status">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{installing
|
{installing
|
||||||
? 'Hermes is finishing install. This usually takes under a minute on first run.'
|
? t.onboarding.preparingInstall
|
||||||
: 'Starting Hermes…'}
|
: t.onboarding.starting}
|
||||||
</p>
|
</p>
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
@ -272,6 +257,8 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
|
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@ -279,9 +266,9 @@ function Header() {
|
|||||||
<Sparkles className="size-5" />
|
<Sparkles className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Let's get you setup with Hermes Agent</h2>
|
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
|
||||||
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||||
Connect a model provider to start chatting. Most options take one click.
|
{t.onboarding.headerDesc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -290,7 +277,6 @@ function Header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FEATURED_ID = 'nous'
|
export const FEATURED_ID = 'nous'
|
||||||
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
|
|
||||||
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
||||||
|
|
||||||
const readShowAll = () => {
|
const readShowAll = () => {
|
||||||
@ -312,6 +298,7 @@ const persistShowAll = (value: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const { manual, mode, providers } = useStore($desktopOnboarding)
|
const { manual, mode, providers } = useStore($desktopOnboarding)
|
||||||
const [showAll, setShowAll] = useState(readShowAll)
|
const [showAll, setShowAll] = useState(readShowAll)
|
||||||
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
|
||||||
@ -335,7 +322,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providers === null) {
|
if (providers === null) {
|
||||||
return <Status>Looking up providers...</Status>
|
return <Status>{t.onboarding.lookingUpProviders}</Status>
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
|
const select = (p: OAuthProvider) => void startProviderOAuth(p, ctx)
|
||||||
@ -363,7 +350,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
onClick={() => setShowAll(persistShowAll(!showAll))}
|
onClick={() => setShowAll(persistShowAll(!showAll))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{showAll ? 'Collapse' : 'Other providers'}
|
{showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
|
||||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@ -377,7 +364,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
onClick={() => setOnboardingMode('apikey')}
|
onClick={() => setOnboardingMode('apikey')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
I have an API key
|
{t.onboarding.haveApiKey}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -388,13 +375,15 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
|||||||
// the skip so it never re-nags. The user connects a provider any time from
|
// the skip so it never re-nags. The user connects a provider any time from
|
||||||
// Settings → Providers. Rendered only on the unconfigured first-run flow.
|
// Settings → Providers. Rendered only on the unconfigured first-run flow.
|
||||||
function ChooseLaterLink() {
|
function ChooseLaterLink() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
className="text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => dismissFirstRunOnboarding()}
|
onClick={() => dismissFirstRunOnboarding()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
I'll choose a provider later
|
{t.onboarding.chooseLater}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -406,6 +395,7 @@ export function FeaturedProviderRow({
|
|||||||
onSelect: (provider: OAuthProvider) => void
|
onSelect: (provider: OAuthProvider) => void
|
||||||
provider: OAuthProvider
|
provider: OAuthProvider
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const loggedIn = provider.status?.logged_in
|
const loggedIn = provider.status?.logged_in
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -426,11 +416,11 @@ export function FeaturedProviderRow({
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
|
<span className="inline-flex items-center gap-1.5 bg-primary px-2 py-0.5 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-primary-foreground">
|
||||||
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
|
<span aria-hidden="true" className="dither inline-block size-2 shrink-0" />
|
||||||
Recommended
|
{t.onboarding.recommended}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.featuredPitch}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -438,15 +428,19 @@ export function FeaturedProviderRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConnectedTag() {
|
function ConnectedTag() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
<span className="inline-flex items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
<Check className="size-3" />
|
<Check className="size-3" />
|
||||||
Connected
|
{t.onboarding.connected}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||||
@ -455,7 +449,7 @@ export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
|||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@ -469,6 +463,7 @@ export function ProviderRow({
|
|||||||
onSelect: (provider: OAuthProvider) => void
|
onSelect: (provider: OAuthProvider) => void
|
||||||
provider: OAuthProvider
|
provider: OAuthProvider
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const loggedIn = provider.status?.logged_in
|
const loggedIn = provider.status?.logged_in
|
||||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||||
|
|
||||||
@ -485,7 +480,9 @@ export function ProviderRow({
|
|||||||
</span>
|
</span>
|
||||||
{loggedIn ? <ConnectedTag /> : null}
|
{loggedIn ? <ConnectedTag /> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
|
{t.onboarding.flowSubtitles[provider.flow]}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@ -514,6 +511,7 @@ export function ApiKeyForm({
|
|||||||
options?: ApiKeyOption[]
|
options?: ApiKeyOption[]
|
||||||
redactedValue?: (envKey: string) => null | string | undefined
|
redactedValue?: (envKey: string) => null | string | undefined
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@ -551,6 +549,8 @@ export function ApiKeyForm({
|
|||||||
// Only require a non-empty value — no length/format validation, so a short
|
// Only require a non-empty value — no length/format validation, so a short
|
||||||
// or unusual key can't block the user from continuing.
|
// or unusual key can't block the user from continuing.
|
||||||
const canSave = value.trim().length >= 1
|
const canSave = value.trim().length >= 1
|
||||||
|
const optionCopy = t.onboarding.apiKeyOptions[option.id]
|
||||||
|
const optionDescription = optionCopy?.description ?? option.description
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!canSave || saving) {
|
if (!canSave || saving) {
|
||||||
@ -564,7 +564,7 @@ export function ApiKeyForm({
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setValue('')
|
setValue('')
|
||||||
} else {
|
} else {
|
||||||
setError(result.message ?? 'Could not save credential.')
|
setError(result.message ?? t.onboarding.couldNotSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@ -579,7 +579,7 @@ export function ApiKeyForm({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-3" />
|
<ChevronLeft className="size-3" />
|
||||||
Back to sign in
|
{t.onboarding.backToSignIn}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -602,15 +602,19 @@ export function ApiKeyForm({
|
|||||||
<Check className="size-3.5 text-muted-foreground" />
|
<Check className="size-3.5 text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
|
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
|
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
|
<p className="text-sm leading-6 text-muted-foreground">{optionDescription}</p>
|
||||||
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
|
{option.docsUrl ? <DocsLink href={option.docsUrl}>{t.onboarding.getKey}</DocsLink> : null}
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@ -619,7 +623,7 @@ export function ApiKeyForm({
|
|||||||
onChange={e => setValue(e.target.value)}
|
onChange={e => setValue(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||||
placeholder={
|
placeholder={
|
||||||
currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
|
currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
|
||||||
}
|
}
|
||||||
type={isLocal ? 'text' : 'password'}
|
type={isLocal ? 'text' : 'password'}
|
||||||
value={value}
|
value={value}
|
||||||
@ -631,13 +635,13 @@ export function ApiKeyForm({
|
|||||||
<div>
|
<div>
|
||||||
{alreadySet && onClear ? (
|
{alreadySet && onClear ? (
|
||||||
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
|
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
|
||||||
Remove
|
{t.common.remove}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={!canSave || saving} onClick={() => void submit()}>
|
<Button disabled={!canSave || saving} onClick={() => void submit()}>
|
||||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||||
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
|
{saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -645,21 +649,22 @@ export function ApiKeyForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
|
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
|
||||||
|
const { t } = useI18n()
|
||||||
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
|
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
|
||||||
|
|
||||||
if (flow.status === 'starting') {
|
if (flow.status === 'starting') {
|
||||||
return <Status>Starting sign-in for {title}...</Status>
|
return <Status>{t.onboarding.startingSignIn(title)}</Status>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow.status === 'submitting') {
|
if (flow.status === 'submitting') {
|
||||||
return <Status>Verifying your code with {title}...</Status>
|
return <Status>{t.onboarding.verifyingCode(title)}</Status>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flow.status === 'success') {
|
if (flow.status === 'success') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
{title} connected. Picking a default model...
|
{t.onboarding.connectedPicking(title)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -672,11 +677,11 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
{flow.message || 'Sign-in failed. Try again.'}
|
{flow.message || t.onboarding.signInFailed}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={cancelOnboardingFlow} variant="outline">
|
<Button onClick={cancelOnboardingFlow} variant="outline">
|
||||||
Pick a different provider
|
{t.onboarding.pickDifferentProvider}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -685,23 +690,23 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||||||
|
|
||||||
if (flow.status === 'awaiting_user') {
|
if (flow.status === 'awaiting_user') {
|
||||||
return (
|
return (
|
||||||
<Step title={`Sign in with ${title}`}>
|
<Step title={t.onboarding.signInWith(title)}>
|
||||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
<ol className="list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
||||||
<li>We opened {title} in your browser.</li>
|
<li>{t.onboarding.openedBrowser(title)}</li>
|
||||||
<li>Authorize Hermes there.</li>
|
<li>{t.onboarding.authorizeThere}</li>
|
||||||
<li>Copy the authorization code and paste it below.</li>
|
<li>{t.onboarding.copyAuthCode}</li>
|
||||||
</ol>
|
</ol>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={e => setOnboardingCode(e.target.value)}
|
onChange={e => setOnboardingCode(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
|
onKeyDown={e => e.key === 'Enter' && void submitOnboardingCode(ctx)}
|
||||||
placeholder="Paste authorization code"
|
placeholder={t.onboarding.pasteAuthCode}
|
||||||
value={flow.code}
|
value={flow.code}
|
||||||
/>
|
/>
|
||||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open authorization page</DocsLink>}>
|
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenAuthPage}</DocsLink>}>
|
||||||
<CancelBtn />
|
<CancelBtn />
|
||||||
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
|
<Button disabled={!flow.code.trim()} onClick={() => void submitOnboardingCode(ctx)}>
|
||||||
Continue
|
{t.common.continue}
|
||||||
</Button>
|
</Button>
|
||||||
</FlowFooter>
|
</FlowFooter>
|
||||||
</Step>
|
</Step>
|
||||||
@ -710,15 +715,14 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||||||
|
|
||||||
if (flow.status === 'awaiting_browser') {
|
if (flow.status === 'awaiting_browser') {
|
||||||
return (
|
return (
|
||||||
<Step title={`Sign in with ${title}`}>
|
<Step title={t.onboarding.signInWith(title)}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to
|
{t.onboarding.autoBrowser(title)}
|
||||||
copy or paste.
|
|
||||||
</p>
|
</p>
|
||||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
|
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Waiting for you to authorize...
|
{t.onboarding.waitingAuthorize}
|
||||||
</span>
|
</span>
|
||||||
<CancelBtn size="sm" />
|
<CancelBtn size="sm" />
|
||||||
</FlowFooter>
|
</FlowFooter>
|
||||||
@ -728,19 +732,18 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||||||
|
|
||||||
if (flow.status === 'external_pending') {
|
if (flow.status === 'external_pending') {
|
||||||
return (
|
return (
|
||||||
<Step title={`Sign in with ${title}`}>
|
<Step title={t.onboarding.signInWith(title)}>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{title} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed
|
{t.onboarding.externalPending(title)}
|
||||||
in":
|
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
|
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
|
||||||
<FlowFooter
|
<FlowFooter
|
||||||
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{title} docs</DocsLink> : null}
|
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink> : null}
|
||||||
>
|
>
|
||||||
<CancelBtn />
|
<CancelBtn />
|
||||||
<Button onClick={() => void recheckExternalSignin(ctx)}>
|
<Button onClick={() => void recheckExternalSignin(ctx)}>
|
||||||
<Check className="size-4" />
|
<Check className="size-4" />
|
||||||
I've signed in
|
{t.onboarding.signedIn}
|
||||||
</Button>
|
</Button>
|
||||||
</FlowFooter>
|
</FlowFooter>
|
||||||
</Step>
|
</Step>
|
||||||
@ -752,13 +755,13 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Step title={`Sign in with ${title}`}>
|
<Step title={t.onboarding.signInWith(title)}>
|
||||||
<p className="text-sm text-muted-foreground">We opened {title} in your browser. Enter this code there:</p>
|
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
|
||||||
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
|
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
|
||||||
<FlowFooter left={<DocsLink href={flow.start.verification_url}>Re-open verification page</DocsLink>}>
|
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
|
||||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
Waiting for you to authorize...
|
{t.onboarding.waitingAuthorize}
|
||||||
</span>
|
</span>
|
||||||
<CancelBtn size="sm" />
|
<CancelBtn size="sm" />
|
||||||
</FlowFooter>
|
</FlowFooter>
|
||||||
@ -786,11 +789,13 @@ function CodeBlock({
|
|||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
text: string
|
text: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
|
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
|
||||||
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
|
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
|
||||||
<Button onClick={onCopy} size="sm" variant="outline">
|
<Button onClick={onCopy} size="sm" variant="outline">
|
||||||
{copied ? <Check className="size-4" /> : 'Copy'}
|
{copied ? <Check className="size-4" /> : t.onboarding.copy}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -806,9 +811,11 @@ function FlowFooter({ children, left }: { children: React.ReactNode; left?: Reac
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
|
function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
|
<Button onClick={cancelOnboardingFlow} size={size} variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -820,6 +827,7 @@ function ConfirmingModelPanel({
|
|||||||
ctx: OnboardingContext
|
ctx: OnboardingContext
|
||||||
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
|
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
// Local state controls whether the model picker dialog is open.
|
// Local state controls whether the model picker dialog is open.
|
||||||
// We reuse the existing ModelPickerDialog component (the same picker
|
// We reuse the existing ModelPickerDialog component (the same picker
|
||||||
// available from the chat shell) rather than building an inline
|
// available from the chat shell) rather than building an inline
|
||||||
@ -845,34 +853,34 @@ function ConfirmingModelPanel({
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||||
<Check className="size-4 shrink-0" />
|
<Check className="size-4 shrink-0" />
|
||||||
<span>{flow.label} connected.</span>
|
<span>{t.onboarding.connectedProvider(flow.label)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t.onboarding.defaultModel}</p>
|
||||||
{freeTier === true && (
|
{freeTier === true && (
|
||||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||||
Free tier
|
{t.onboarding.freeTier}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{freeTier === false && (
|
{freeTier === false && (
|
||||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||||
Pro
|
{t.onboarding.pro}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
||||||
{price && (price.input || price.output) && (
|
{price && (price.input || price.output) && (
|
||||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||||
{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
|
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
||||||
Change
|
{t.onboarding.change}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -880,7 +888,7 @@ function ConfirmingModelPanel({
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
|
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
|
||||||
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
|
||||||
Start chatting
|
{t.onboarding.startChatting}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ErrorState } from '@/components/ui/error-state'
|
import { ErrorState } from '@/components/ui/error-state'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
export interface ErrorBoundaryFallbackProps {
|
export interface ErrorBoundaryFallbackProps {
|
||||||
error: Error
|
error: Error
|
||||||
@ -52,21 +53,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
|
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
|
||||||
<ErrorState
|
<ErrorState
|
||||||
className="w-full max-w-[28rem]"
|
className="w-full max-w-[28rem]"
|
||||||
description={error.message || 'The view hit an unexpected error. Your chats and settings are safe.'}
|
description={error.message || t.errors.boundaryDesc}
|
||||||
title="Something broke in the interface"
|
title={t.errors.boundaryTitle}
|
||||||
>
|
>
|
||||||
<Button className="font-semibold" onClick={reset} size="lg">
|
<Button className="font-semibold" onClick={reset} size="lg">
|
||||||
Try again
|
{t.common.retry}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => window.location.reload()} variant="text">
|
<Button onClick={() => window.location.reload()} variant="text">
|
||||||
Reload window
|
{t.errors.reloadWindow}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
|
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
|
||||||
Open logs
|
{t.errors.openLogs}
|
||||||
</Button>
|
</Button>
|
||||||
</ErrorState>
|
</ErrorState>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||||
|
|
||||||
import type { HermesGateway } from '../hermes'
|
import type { HermesGateway } from '../hermes'
|
||||||
@ -42,6 +43,8 @@ export function ModelPickerDialog({
|
|||||||
onSelect,
|
onSelect,
|
||||||
contentClassName
|
contentClassName
|
||||||
}: ModelPickerDialogProps) {
|
}: ModelPickerDialogProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.modelPicker
|
||||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||||
// Own the search term so we can filter manually. cmdk's built-in
|
// Own the search term so we can filter manually. cmdk's built-in
|
||||||
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
||||||
@ -97,9 +100,9 @@ export function ModelPickerDialog({
|
|||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
|
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
|
||||||
<DialogHeader className="border-b border-border px-4 py-3">
|
<DialogHeader className="border-b border-border px-4 py-3">
|
||||||
<DialogTitle>Switch model</DialogTitle>
|
<DialogTitle>{copy.title}</DialogTitle>
|
||||||
<DialogDescription className="font-mono text-xs leading-relaxed">
|
<DialogDescription className="font-mono text-xs leading-relaxed">
|
||||||
current: {optionsModel || currentModel || '(unknown)'}
|
{copy.current} {optionsModel || currentModel || copy.unknown}
|
||||||
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
|
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -108,11 +111,11 @@ export function ModelPickerDialog({
|
|||||||
<CommandInput
|
<CommandInput
|
||||||
autoFocus
|
autoFocus
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder="Filter providers and models..."
|
placeholder={copy.search}
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
<CommandList className="max-h-96">
|
<CommandList className="max-h-96">
|
||||||
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
|
{!loading && !error && <CommandEmpty>{copy.noModels}</CommandEmpty>}
|
||||||
<ModelResults
|
<ModelResults
|
||||||
currentModel={optionsModel || currentModel}
|
currentModel={optionsModel || currentModel}
|
||||||
currentProvider={optionsProvider || currentProvider}
|
currentProvider={optionsProvider || currentProvider}
|
||||||
@ -132,15 +135,15 @@ export function ModelPickerDialog({
|
|||||||
disabled={!sessionId}
|
disabled={!sessionId}
|
||||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
||||||
/>
|
/>
|
||||||
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
|
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={addProvider} variant="ghost">
|
<Button onClick={addProvider} variant="ghost">
|
||||||
Add provider
|
{copy.addProvider}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -166,6 +169,9 @@ function ModelResults({
|
|||||||
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
||||||
search: string
|
search: string
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.modelPicker
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingResults />
|
return <LoadingResults />
|
||||||
}
|
}
|
||||||
@ -173,7 +179,7 @@ function ModelResults({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<InlineNotice kind="error" title="Could not load models">
|
<InlineNotice kind="error" title={copy.loadFailed}>
|
||||||
{error}
|
{error}
|
||||||
</InlineNotice>
|
</InlineNotice>
|
||||||
</div>
|
</div>
|
||||||
@ -181,7 +187,7 @@ function ModelResults({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
|
return <div className="px-4 py-6 text-sm text-muted-foreground">{copy.noAuthenticatedProviders}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
@ -241,14 +247,14 @@ function ModelResults({
|
|||||||
value={`${provider.slug}:${model}`}
|
value={`${provider.slug}:${model}`}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{model}</span>
|
<span className="min-w-0 flex-1 truncate">{model}</span>
|
||||||
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}
|
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>}
|
||||||
<ModelPrice isCurrent={isCurrent} price={price} />
|
<ModelPrice isCurrent={isCurrent} price={price} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{unavailable.size > 0 && (
|
{unavailable.size > 0 && (
|
||||||
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
||||||
Pro models need a paid Nous subscription.
|
{copy.proNeedsSubscription}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@ -261,6 +267,9 @@ function ModelResults({
|
|||||||
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
||||||
// Renders nothing when pricing is unavailable for the model.
|
// Renders nothing when pricing is unavailable for the model.
|
||||||
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.modelPicker
|
||||||
|
|
||||||
if (!price || (!price.input && !price.output)) {
|
if (!price || (!price.input && !price.output)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -273,7 +282,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
|
|||||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Free
|
{copy.free}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -284,7 +293,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
|
|||||||
'shrink-0 text-[0.66rem] tabular-nums',
|
'shrink-0 text-[0.66rem] tabular-nums',
|
||||||
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
title="Input / Output price per million tokens"
|
title={copy.priceTitle}
|
||||||
>
|
>
|
||||||
{price.input || '?'} / {price.output || '?'}
|
{price.input || '?'} / {price.output || '?'}
|
||||||
</span>
|
</span>
|
||||||
@ -304,15 +313,18 @@ function LoadingResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.modelPicker
|
||||||
|
|
||||||
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
|
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
|
||||||
const tierBadge =
|
const tierBadge =
|
||||||
provider.free_tier === true ? (
|
provider.free_tier === true ? (
|
||||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||||
Free tier
|
{copy.freeTier}
|
||||||
</span>
|
</span>
|
||||||
) : provider.free_tier === false ? (
|
) : provider.free_tier === false ? (
|
||||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||||
Pro
|
{copy.pro}
|
||||||
</span>
|
</span>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import type { HermesGateway } from '@/hermes'
|
import type { HermesGateway } from '@/hermes'
|
||||||
import { getGlobalModelOptions } from '@/hermes'
|
import { getGlobalModelOptions } from '@/hermes'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||||
import {
|
import {
|
||||||
$visibleModels,
|
$visibleModels,
|
||||||
@ -32,6 +33,8 @@ export function ModelVisibilityDialog({
|
|||||||
open,
|
open,
|
||||||
sessionId
|
sessionId
|
||||||
}: ModelVisibilityDialogProps) {
|
}: ModelVisibilityDialogProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.modelVisibility
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const stored = useStore($visibleModels)
|
const stored = useStore($visibleModels)
|
||||||
|
|
||||||
@ -76,7 +79,7 @@ export function ModelVisibilityDialog({
|
|||||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
|
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
|
||||||
<DialogHeader className="px-3 pb-1 pt-3">
|
<DialogHeader className="px-3 pb-1 pt-3">
|
||||||
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
|
<DialogTitle className="text-[0.8125rem]">{copy.title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
@ -84,7 +87,7 @@ export function ModelVisibilityDialog({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||||
onChange={event => setSearch(event.target.value)}
|
onChange={event => setSearch(event.target.value)}
|
||||||
placeholder="Search models"
|
placeholder={copy.search}
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
@ -93,7 +96,7 @@ export function ModelVisibilityDialog({
|
|||||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||||
{providers.length === 0 ? (
|
{providers.length === 0 ? (
|
||||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||||
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : 'No authenticated providers.'}
|
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : copy.noAuthenticatedProviders}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
providers.map(provider => {
|
providers.map(provider => {
|
||||||
@ -140,7 +143,7 @@ export function ModelVisibilityDialog({
|
|||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Add provider…
|
{copy.addProvider}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { triggerHaptic } from '@/lib/haptics'
|
import { triggerHaptic } from '@/lib/haptics'
|
||||||
import { KeyRound, Loader2, Lock } from '@/lib/icons'
|
import { KeyRound, Loader2, Lock } from '@/lib/icons'
|
||||||
import { $gateway } from '@/store/gateway'
|
import { $gateway } from '@/store/gateway'
|
||||||
@ -34,6 +35,8 @@ import { $secretRequest, $sudoRequest, clearSecretRequest, clearSudoRequest } fr
|
|||||||
// backdrop-dismiss path.
|
// backdrop-dismiss path.
|
||||||
|
|
||||||
function SudoDialog() {
|
function SudoDialog() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.prompts
|
||||||
const request = useStore($sudoRequest)
|
const request = useStore($sudoRequest)
|
||||||
const gateway = useStore($gateway)
|
const gateway = useStore($gateway)
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@ -51,7 +54,7 @@ function SudoDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send sudo password')
|
notifyError(new Error(copy.gatewayDisconnected), copy.sudoSendFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -66,11 +69,11 @@ function SudoDialog() {
|
|||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
clearSudoRequest(request.sessionId, request.requestId)
|
clearSudoRequest(request.sessionId, request.requestId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Could not send sudo password')
|
notifyError(error, copy.sudoSendFailed)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[gateway, request]
|
[copy.gatewayDisconnected, copy.sudoSendFailed, gateway, request]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cancel → empty password. The backend treats an empty sudo response as a
|
// Cancel → empty password. The backend treats an empty sudo response as a
|
||||||
@ -102,11 +105,9 @@ function SudoDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Lock className="size-4 text-primary" />
|
<Lock className="size-4 text-primary" />
|
||||||
Administrator password
|
{copy.sudoTitle}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>{copy.sudoDesc}</DialogDescription>
|
||||||
Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form className="grid gap-3" onSubmit={onSubmit}>
|
<form className="grid gap-3" onSubmit={onSubmit}>
|
||||||
@ -114,16 +115,16 @@ function SudoDialog() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onChange={event => setPassword(event.target.value)}
|
onChange={event => setPassword(event.target.value)}
|
||||||
placeholder="sudo password"
|
placeholder={copy.sudoPlaceholder}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
|
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={submitting} type="submit">
|
<Button disabled={submitting} type="submit">
|
||||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : t.common.send}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@ -133,6 +134,8 @@ function SudoDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SecretDialog() {
|
function SecretDialog() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const copy = t.prompts
|
||||||
const request = useStore($secretRequest)
|
const request = useStore($secretRequest)
|
||||||
const gateway = useStore($gateway)
|
const gateway = useStore($gateway)
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
@ -150,7 +153,7 @@ function SecretDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gateway) {
|
if (!gateway) {
|
||||||
notifyError(new Error('Hermes gateway is not connected'), 'Could not send secret')
|
notifyError(new Error(copy.gatewayDisconnected), copy.secretSendFailed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -165,11 +168,11 @@ function SecretDialog() {
|
|||||||
triggerHaptic('submit')
|
triggerHaptic('submit')
|
||||||
clearSecretRequest(request.sessionId, request.requestId)
|
clearSecretRequest(request.sessionId, request.requestId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyError(error, 'Could not send secret')
|
notifyError(error, copy.secretSendFailed)
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[gateway, request]
|
[copy.gatewayDisconnected, copy.secretSendFailed, gateway, request]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
@ -199,9 +202,9 @@ function SecretDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<KeyRound className="size-4 text-primary" />
|
<KeyRound className="size-4 text-primary" />
|
||||||
{request.envVar || 'Secret required'}
|
{request.envVar || copy.secretTitle}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{request.prompt || 'Hermes needs a credential to continue.'}</DialogDescription>
|
<DialogDescription>{request.prompt || copy.secretDesc}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form className="grid gap-3" onSubmit={onSubmit}>
|
<form className="grid gap-3" onSubmit={onSubmit}>
|
||||||
@ -209,16 +212,16 @@ function SecretDialog() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
onChange={event => setValue(event.target.value)}
|
onChange={event => setValue(event.target.value)}
|
||||||
placeholder={request.envVar || 'secret value'}
|
placeholder={request.envVar || copy.secretPlaceholder}
|
||||||
type="password"
|
type="password"
|
||||||
value={value}
|
value={value}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
|
<Button disabled={submitting} onClick={() => void send('')} type="button" variant="ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={submitting || !value} type="submit">
|
<Button disabled={submitting || !value} type="submit">
|
||||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : 'Send'}
|
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : t.common.send}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { ActionStatus } from '@/components/ui/action-status'
|
import { ActionStatus } from '@/components/ui/action-status'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { AlertTriangle } from '@/lib/icons'
|
import { AlertTriangle } from '@/lib/icons'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
@ -29,15 +30,20 @@ export function ConfirmDialog({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
confirmLabel = 'Confirm',
|
confirmLabel,
|
||||||
busyLabel = 'Working…',
|
busyLabel,
|
||||||
doneLabel = 'Done',
|
doneLabel,
|
||||||
cancelLabel = 'Cancel',
|
cancelLabel,
|
||||||
destructive = false
|
destructive = false
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||||
const [error, setError] = useState<null | string>(null)
|
const [error, setError] = useState<null | string>(null)
|
||||||
const busy = status === 'saving' || status === 'done'
|
const busy = status === 'saving' || status === 'done'
|
||||||
|
const resolvedConfirmLabel = confirmLabel ?? t.common.confirm
|
||||||
|
const resolvedBusyLabel = busyLabel ?? t.common.loading
|
||||||
|
const resolvedDoneLabel = doneLabel ?? t.common.done
|
||||||
|
const resolvedCancelLabel = cancelLabel ?? t.common.cancel
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@ -60,7 +66,7 @@ export function ConfirmDialog({
|
|||||||
window.setTimeout(onClose, 600)
|
window.setTimeout(onClose, 600)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
setError(err instanceof Error ? err.message : t.errors.genericFailure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,10 +97,10 @@ export function ConfirmDialog({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
|
||||||
{cancelLabel}
|
{resolvedCancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
|
<Button disabled={busy} onClick={() => void run()} variant={destructive ? 'destructive' : 'default'}>
|
||||||
<ActionStatus busy={busyLabel} done={doneLabel} idle={confirmLabel} state={status} />
|
<ActionStatus busy={resolvedBusyLabel} done={resolvedDoneLabel} idle={resolvedConfirmLabel} state={status} />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
@ -42,6 +43,8 @@ function DialogContent({
|
|||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@ -60,13 +63,13 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
<DialogPrimitive.Close asChild data-slot="dialog-close-button">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Close"
|
aria-label={t.common.close}
|
||||||
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
className="absolute right-2.5 top-2.5 text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Codicon name="close" size="1rem" />
|
<Codicon name="close" size="1rem" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t.common.close}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label="pagination"
|
aria-label={t.ui.pagination.label}
|
||||||
className={cn('mx-auto flex w-full justify-center', className)}
|
className={cn('mx-auto flex w-full justify-center', className)}
|
||||||
data-slot="pagination"
|
data-slot="pagination"
|
||||||
{...props}
|
{...props}
|
||||||
@ -48,9 +51,11 @@ function PaginationButton({ className, isActive, ...props }: PaginationButtonPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
|
function PaginationPrevious({ className, ...props }: React.ComponentProps<'button'>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Go to previous page"
|
aria-label={t.ui.pagination.previousAria}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||||
className
|
className
|
||||||
@ -60,15 +65,17 @@ function PaginationPrevious({ className, ...props }: React.ComponentProps<'butto
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Codicon name="chevron-left" size="0.75rem" />
|
<Codicon name="chevron-left" size="0.75rem" />
|
||||||
<span>Prev</span>
|
<span>{t.ui.pagination.previous}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
|
function PaginationNext({ className, ...props }: React.ComponentProps<'button'>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Go to next page"
|
aria-label={t.ui.pagination.nextAria}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
'inline-flex h-5 items-center justify-center gap-0.5 rounded border border-transparent px-1 text-[0.6875rem] leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-45',
|
||||||
className
|
className
|
||||||
@ -77,7 +84,7 @@ function PaginationNext({ className, ...props }: React.ComponentProps<'button'>)
|
|||||||
type="button"
|
type="button"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span>Next</span>
|
<span>{t.ui.pagination.next}</span>
|
||||||
<Codicon name="chevron-right" size="0.75rem" />
|
<Codicon name="chevron-right" size="0.75rem" />
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ReactNode, RefObject } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { Loader2, Search } from '@/lib/icons'
|
import { Loader2, Search } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ export function SearchField({
|
|||||||
trailingAction,
|
trailingAction,
|
||||||
'aria-label': ariaLabel
|
'aria-label': ariaLabel
|
||||||
}: SearchFieldProps) {
|
}: SearchFieldProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
const clear = onClear ?? (() => onChange(''))
|
const clear = onClear ?? (() => onChange(''))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,7 +66,7 @@ export function SearchField({
|
|||||||
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
|
<Loader2 className="pointer-events-none size-3.5 shrink-0 animate-spin text-muted-foreground/70" />
|
||||||
) : value ? (
|
) : value ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label="Clear search"
|
aria-label={t.ui.search.clear}
|
||||||
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
className="shrink-0 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Dialog as SheetPrimitive } from 'radix-ui'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import { Codicon } from '@/components/ui/codicon'
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
@ -45,6 +46,8 @@ function SheetContent({
|
|||||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
@ -66,9 +69,12 @@ function SheetContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<SheetPrimitive.Close className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
<SheetPrimitive.Close
|
||||||
|
aria-label={t.common.close}
|
||||||
|
className="absolute top-3 right-3 rounded-md p-1 text-(--ui-text-tertiary) opacity-70 ring-offset-background transition-opacity hover:bg-(--chrome-action-hover) hover:text-foreground hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||||
|
>
|
||||||
<Codicon name="close" size="1rem" />
|
<Codicon name="close" size="1rem" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t.common.close}</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useIsMobile } from '@/hooks/use-mobile'
|
import { useIsMobile } from '@/hooks/use-mobile'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
import { PanelLeftIcon } from '@/lib/icons'
|
import { PanelLeftIcon } from '@/lib/icons'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@ -152,6 +153,7 @@ function Sidebar({
|
|||||||
collapsible?: 'offcanvas' | 'icon' | 'none'
|
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
if (collapsible === 'none') {
|
if (collapsible === 'none') {
|
||||||
return (
|
return (
|
||||||
@ -181,8 +183,8 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>{t.ui.sidebar.title}</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>{t.ui.sidebar.description}</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@ -240,6 +242,7 @@ function Sidebar({
|
|||||||
|
|
||||||
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -255,17 +258,18 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">{t.ui.sidebar.toggle}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar } = useSidebar()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Toggle Sidebar"
|
aria-label={t.ui.sidebar.toggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
|
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
|
||||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||||
@ -279,7 +283,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
|||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
title="Toggle Sidebar"
|
title={t.ui.sidebar.toggle}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,14 +4,38 @@ import type { Translations } from './types'
|
|||||||
|
|
||||||
export const en: Translations = {
|
export const en: Translations = {
|
||||||
common: {
|
common: {
|
||||||
|
apply: 'Apply',
|
||||||
|
back: 'Back',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
|
change: 'Change',
|
||||||
|
choose: 'Choose',
|
||||||
|
clear: 'Clear',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
|
collapse: 'Collapse',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
|
connect: 'Connect',
|
||||||
|
connecting: 'Connecting',
|
||||||
|
continue: 'Continue',
|
||||||
|
copied: 'Copied',
|
||||||
|
copy: 'Copy',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
|
docs: 'Docs',
|
||||||
|
done: 'Done',
|
||||||
|
error: 'Error',
|
||||||
|
free: 'Free',
|
||||||
|
loading: 'Loading…',
|
||||||
|
notSet: 'Not set',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
|
remove: 'Remove',
|
||||||
|
replace: 'Replace',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
run: 'Run',
|
||||||
|
send: 'Send',
|
||||||
|
set: 'Set',
|
||||||
|
skip: 'Skip',
|
||||||
|
update: 'Update',
|
||||||
on: 'On',
|
on: 'On',
|
||||||
off: 'Off'
|
off: 'Off'
|
||||||
},
|
},
|
||||||
@ -198,6 +222,227 @@ export const en: Translations = {
|
|||||||
hoursAgo: count => `${count} hours ago`,
|
hoursAgo: count => `${count} hours ago`,
|
||||||
daysAgo: count => `${count} days ago`
|
daysAgo: count => `${count} days ago`
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
config: {
|
||||||
|
none: 'None',
|
||||||
|
noneParen: '(none)',
|
||||||
|
notSet: 'Not set',
|
||||||
|
commaSeparated: 'comma-separated values',
|
||||||
|
loading: 'Loading Hermes configuration...',
|
||||||
|
emptyTitle: 'Nothing to configure',
|
||||||
|
emptyDesc: 'This section has no adjustable settings.',
|
||||||
|
failedLoad: 'Settings failed to load',
|
||||||
|
autosaveFailed: 'Autosave failed',
|
||||||
|
imported: 'Config imported',
|
||||||
|
invalidJson: 'Invalid config JSON'
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
pasteKey: 'Paste key',
|
||||||
|
pasteLabelKey: label => `Paste ${label} key`,
|
||||||
|
optional: 'Optional',
|
||||||
|
enterValueFirst: 'Enter a value first.',
|
||||||
|
couldNotSave: 'Could not save credential.',
|
||||||
|
remove: 'Remove',
|
||||||
|
or: 'or',
|
||||||
|
escToCancel: 'esc to cancel',
|
||||||
|
getKey: 'Get a key',
|
||||||
|
saving: 'Saving'
|
||||||
|
},
|
||||||
|
envActions: {
|
||||||
|
actionsFor: label => `Actions for ${label}`,
|
||||||
|
credentialActions: 'Credential actions',
|
||||||
|
docs: 'Docs',
|
||||||
|
hideValue: 'Hide value',
|
||||||
|
revealValue: 'Reveal value',
|
||||||
|
replace: 'Replace',
|
||||||
|
set: 'Set',
|
||||||
|
clear: 'Clear'
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
loading: 'Loading gateway settings...',
|
||||||
|
unavailableTitle: 'Gateway settings unavailable',
|
||||||
|
unavailableDesc: 'The desktop IPC bridge does not expose gateway settings.',
|
||||||
|
title: 'Gateway Connection',
|
||||||
|
envOverride: 'env override',
|
||||||
|
intro:
|
||||||
|
'Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control an already-running Hermes backend on another machine or behind a trusted proxy. Pick a profile below to give it its own remote host.',
|
||||||
|
appliesTo: 'Applies to',
|
||||||
|
allProfiles: 'All profiles',
|
||||||
|
defaultConnection: 'Default connection for every profile that has no override of its own.',
|
||||||
|
profileConnection: profile => `Connection used only when “${profile}” is the active profile. Set it to Local to inherit the default.`,
|
||||||
|
envOverrideTitle: 'Environment variables are controlling this desktop session.',
|
||||||
|
envOverrideDesc:
|
||||||
|
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
|
||||||
|
localTitle: 'Local gateway',
|
||||||
|
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
|
||||||
|
remoteTitle: 'Remote gateway',
|
||||||
|
remoteDesc:
|
||||||
|
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
|
||||||
|
remoteUrlTitle: 'Remote URL',
|
||||||
|
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
|
||||||
|
probing: 'Checking how this gateway authenticates…',
|
||||||
|
probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
|
||||||
|
signedIn: 'Signed in',
|
||||||
|
signIn: 'Sign in',
|
||||||
|
signOut: 'Sign out',
|
||||||
|
signInWith: provider => `Sign in with ${provider}`,
|
||||||
|
authTitle: 'Authentication',
|
||||||
|
authSignedInPassword:
|
||||||
|
'This gateway uses a username and password. You are signed in; the session refreshes automatically.',
|
||||||
|
authSignedInOauth: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.',
|
||||||
|
authNeedsPassword: 'This gateway uses a username and password. Sign in to authorize this desktop app.',
|
||||||
|
authNeedsOauth: provider => `This gateway uses OAuth. Sign in with ${provider} to authorize this desktop app.`,
|
||||||
|
tokenTitle: 'Session token',
|
||||||
|
tokenDesc:
|
||||||
|
'The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token.',
|
||||||
|
existingToken: value => `Existing token ${value}`,
|
||||||
|
savedToken: 'saved',
|
||||||
|
pasteSessionToken: 'Paste session token',
|
||||||
|
testRemote: 'Test remote',
|
||||||
|
saveForRestart: 'Save for next restart',
|
||||||
|
saveAndReconnect: 'Save and reconnect',
|
||||||
|
diagnostics: 'Diagnostics',
|
||||||
|
diagnosticsDesc: 'Reveal desktop.log in your file manager — useful when the gateway fails to start.',
|
||||||
|
openLogs: 'Open logs',
|
||||||
|
incompleteTitle: 'Remote gateway incomplete',
|
||||||
|
incompleteSignIn: 'Enter a remote URL and sign in before switching to remote.',
|
||||||
|
incompleteToken: 'Enter a remote URL and session token before switching to remote.',
|
||||||
|
incompleteSignInTest: 'Enter a remote URL and sign in before testing.',
|
||||||
|
incompleteTokenTest: 'Enter a remote URL and session token before testing.',
|
||||||
|
enterUrlFirst: 'Enter a remote URL first.',
|
||||||
|
restartingTitle: 'Gateway connection restarting',
|
||||||
|
savedTitle: 'Gateway settings saved',
|
||||||
|
restartingMessage: 'Hermes Desktop will reconnect using the saved settings.',
|
||||||
|
savedMessage: 'Saved for the next restart.',
|
||||||
|
connectedTo: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` · Hermes ${version}` : ''}`,
|
||||||
|
reachableTitle: 'Remote gateway reachable',
|
||||||
|
signedOutTitle: 'Signed out',
|
||||||
|
signedOutMessage: 'Cleared the remote gateway session.',
|
||||||
|
failedLoad: 'Gateway settings failed to load',
|
||||||
|
signInFailed: 'Sign-in failed',
|
||||||
|
signOutFailed: 'Sign-out failed',
|
||||||
|
testFailed: 'Remote gateway test failed',
|
||||||
|
applyFailed: 'Could not apply gateway settings',
|
||||||
|
saveFailed: 'Could not save gateway settings'
|
||||||
|
},
|
||||||
|
keys: {
|
||||||
|
loading: 'Loading API keys and credentials...',
|
||||||
|
failedLoad: 'API keys failed to load',
|
||||||
|
empty: 'Nothing configured in this category yet.'
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
loading: 'Loading MCP servers...',
|
||||||
|
failedLoad: 'MCP config failed to load',
|
||||||
|
nameRequiredTitle: 'Name required',
|
||||||
|
nameRequiredMessage: 'Give this MCP server a config key.',
|
||||||
|
objectRequired: 'Server config must be a JSON object',
|
||||||
|
invalidJson: 'Invalid MCP JSON',
|
||||||
|
saveFailed: 'Save failed',
|
||||||
|
removeFailed: 'Remove failed',
|
||||||
|
gatewayUnavailableTitle: 'Gateway unavailable',
|
||||||
|
gatewayUnavailableMessage: 'Reconnect the gateway before reloading MCP.',
|
||||||
|
reloadedTitle: 'MCP tools reloaded',
|
||||||
|
reloadedMessage: 'New tool schemas apply to fresh turns.',
|
||||||
|
reloadFailed: 'MCP reload failed',
|
||||||
|
savedTitle: 'MCP server saved',
|
||||||
|
savedMessage: name => `${name} applies after MCP reload.`,
|
||||||
|
newServer: 'New server',
|
||||||
|
reload: 'Reload MCP',
|
||||||
|
reloading: 'Reloading...',
|
||||||
|
emptyTitle: 'No MCP servers',
|
||||||
|
emptyDesc: 'Add a stdio or HTTP server to expose MCP tools.',
|
||||||
|
disabled: 'disabled',
|
||||||
|
editServer: 'Edit server',
|
||||||
|
name: 'Name',
|
||||||
|
serverJson: 'Server JSON',
|
||||||
|
remove: 'Remove',
|
||||||
|
saveServer: 'Save server'
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
loading: 'Loading model configuration...',
|
||||||
|
appliesDesc: 'Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.',
|
||||||
|
provider: 'Provider',
|
||||||
|
model: 'Model',
|
||||||
|
applying: 'Applying...',
|
||||||
|
auxiliaryTitle: 'Auxiliary models',
|
||||||
|
resetAllToMain: 'Reset all to main',
|
||||||
|
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
||||||
|
setToMain: 'Set to main',
|
||||||
|
change: 'Change',
|
||||||
|
autoUseMain: 'auto · use main model',
|
||||||
|
providerDefault: '(provider default)',
|
||||||
|
tasks: {
|
||||||
|
vision: { label: 'Vision', hint: 'Image analysis' },
|
||||||
|
web_extract: { label: 'Web extract', hint: 'Page summarization' },
|
||||||
|
compression: { label: 'Compression', hint: 'Context compaction' },
|
||||||
|
skills_hub: { label: 'Skills hub', hint: 'Skill search' },
|
||||||
|
approval: { label: 'Approval', hint: 'Smart auto-approve' },
|
||||||
|
mcp: { label: 'MCP', hint: 'MCP tool routing' },
|
||||||
|
title_generation: { label: 'Title gen', hint: 'Session titles' },
|
||||||
|
curator: { label: 'Curator', hint: 'Skill-usage review' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
connectAccount: 'Connect an account',
|
||||||
|
haveApiKey: 'Have an API key instead?',
|
||||||
|
intro: 'Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the app.',
|
||||||
|
connected: 'Connected',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
connectAnother: 'Connect another provider',
|
||||||
|
otherProviders: 'Other providers',
|
||||||
|
noProviderKeys: 'No provider API keys available.',
|
||||||
|
loading: 'Loading providers...'
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
loading: 'Loading archived sessions…',
|
||||||
|
archivedTitle: 'Archived sessions',
|
||||||
|
archivedIntro:
|
||||||
|
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
||||||
|
emptyArchivedTitle: 'Nothing archived',
|
||||||
|
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||||
|
unarchive: 'Unarchive',
|
||||||
|
deletePermanently: 'Delete permanently',
|
||||||
|
messages: count => `${count} ${count === 1 ? 'message' : 'messages'}`,
|
||||||
|
restored: 'Restored',
|
||||||
|
deleteConfirm: title => `Permanently delete "${title}"? This cannot be undone.`,
|
||||||
|
defaultDirTitle: 'Default project directory',
|
||||||
|
defaultDirDesc:
|
||||||
|
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
||||||
|
defaultDirUpdated: 'Default project directory updated',
|
||||||
|
defaultsTo: label => `Defaults to ${label}.`,
|
||||||
|
change: 'Change',
|
||||||
|
choose: 'Choose',
|
||||||
|
clear: 'Clear',
|
||||||
|
notSet: 'Not set',
|
||||||
|
failedLoad: 'Could not load archived sessions',
|
||||||
|
unarchiveFailed: 'Unarchive failed',
|
||||||
|
deleteFailed: 'Delete failed',
|
||||||
|
updateDirFailed: 'Could not update default directory',
|
||||||
|
clearDirFailed: 'Could not clear default directory'
|
||||||
|
},
|
||||||
|
toolsets: {
|
||||||
|
loadingConfig: 'Loading configuration',
|
||||||
|
savedTitle: 'Credential saved',
|
||||||
|
savedMessage: key => `${key} updated.`,
|
||||||
|
removedTitle: 'Credential removed',
|
||||||
|
removedMessage: key => `${key} removed.`,
|
||||||
|
failedSave: key => `Failed to save ${key}`,
|
||||||
|
failedRemove: key => `Failed to remove ${key}`,
|
||||||
|
failedReveal: key => `Failed to reveal ${key}`,
|
||||||
|
removeConfirm: key => `Remove ${key} from .env?`,
|
||||||
|
set: 'Set',
|
||||||
|
notSet: 'Not set',
|
||||||
|
selectedTitle: 'Provider selected',
|
||||||
|
selectedMessage: provider => `${provider} is now active.`,
|
||||||
|
failedSelect: provider => `Failed to select ${provider}`,
|
||||||
|
failedLoad: 'Tool configuration failed to load',
|
||||||
|
noProviderOptions: 'This toolset has no provider options — enable it and it works with your current setup.',
|
||||||
|
noProviders: 'No providers are available for this toolset right now.',
|
||||||
|
ready: 'Ready',
|
||||||
|
nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
|
||||||
|
noApiKeyRequired: 'No API key required.',
|
||||||
|
postSetup: step => `This provider needs an extra setup step (${step}). Run it from the CLI with hermes tools for now.`
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
skills: {
|
skills: {
|
||||||
@ -262,7 +507,18 @@ export const en: Translations = {
|
|||||||
|
|
||||||
commandCenter: {
|
commandCenter: {
|
||||||
close: 'Close command center',
|
close: 'Close command center',
|
||||||
|
paletteTitle: 'Command palette',
|
||||||
|
back: 'Back',
|
||||||
searchPlaceholder: 'Search sessions, views, and actions',
|
searchPlaceholder: 'Search sessions, views, and actions',
|
||||||
|
goTo: 'Go to',
|
||||||
|
commandCenter: 'Command Center',
|
||||||
|
appearance: 'Appearance',
|
||||||
|
settings: 'Settings',
|
||||||
|
changeTheme: 'Change theme...',
|
||||||
|
changeColorMode: 'Change color mode...',
|
||||||
|
settingsFields: 'Settings fields',
|
||||||
|
mcpServers: 'MCP servers',
|
||||||
|
archivedChats: 'Archived chats',
|
||||||
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
|
sections: { sessions: 'Sessions', system: 'System', usage: 'Usage' },
|
||||||
sectionDescriptions: {
|
sectionDescriptions: {
|
||||||
sessions: 'Search and manage sessions',
|
sessions: 'Search and manage sessions',
|
||||||
@ -371,7 +627,79 @@ export const en: Translations = {
|
|||||||
failedUpdate: name => `Failed to update ${name}`,
|
failedUpdate: name => `Failed to update ${name}`,
|
||||||
failedSave: name => `Failed to save ${name}`,
|
failedSave: name => `Failed to save ${name}`,
|
||||||
failedClear: key => `Failed to clear ${key}`,
|
failedClear: key => `Failed to clear ${key}`,
|
||||||
fieldCopy: {},
|
fieldCopy: {
|
||||||
|
TELEGRAM_BOT_TOKEN: {
|
||||||
|
label: 'Bot token',
|
||||||
|
help: 'Create a bot with @BotFather, then paste the token it gives you.',
|
||||||
|
placeholder: 'Paste Telegram bot token'
|
||||||
|
},
|
||||||
|
TELEGRAM_ALLOWED_USERS: {
|
||||||
|
label: 'Allowed Telegram user IDs',
|
||||||
|
help: 'Recommended. Comma-separated numeric IDs from @userinfobot. Without this, anyone can DM your bot.'
|
||||||
|
},
|
||||||
|
TELEGRAM_PROXY: { label: 'Proxy URL', help: 'Only needed on networks where Telegram is blocked.' },
|
||||||
|
DISCORD_BOT_TOKEN: {
|
||||||
|
label: 'Bot token',
|
||||||
|
help: 'Create an application in the Discord Developer Portal, add a bot, then paste its token.'
|
||||||
|
},
|
||||||
|
DISCORD_ALLOWED_USERS: { label: 'Allowed Discord user IDs', help: 'Recommended. Comma-separated Discord user IDs.' },
|
||||||
|
DISCORD_REPLY_TO_MODE: { label: 'Reply style', help: 'first, all, or off.' },
|
||||||
|
DISCORD_ALLOW_ALL_USERS: {
|
||||||
|
label: 'Allow all Discord users',
|
||||||
|
help: 'Development only. When true, anyone can DM the bot without an allowlist.'
|
||||||
|
},
|
||||||
|
DISCORD_HOME_CHANNEL: {
|
||||||
|
label: 'Home channel ID',
|
||||||
|
help: 'Channel where the bot sends proactive messages (cron output, reminders).'
|
||||||
|
},
|
||||||
|
DISCORD_HOME_CHANNEL_NAME: {
|
||||||
|
label: 'Home channel name',
|
||||||
|
help: 'Display name for the home channel in logs and status output.'
|
||||||
|
},
|
||||||
|
BLUEBUBBLES_ALLOW_ALL_USERS: { label: 'Allow all iMessage users', help: 'When true, skip the BlueBubbles allowlist.' },
|
||||||
|
MATTERMOST_ALLOW_ALL_USERS: { label: 'Allow all Mattermost users' },
|
||||||
|
MATTERMOST_HOME_CHANNEL: { label: 'Home channel' },
|
||||||
|
QQ_ALLOW_ALL_USERS: { label: 'Allow all QQ users' },
|
||||||
|
QQBOT_HOME_CHANNEL: { label: 'QQ home channel', help: 'Default channel or group for cron delivery.' },
|
||||||
|
QQBOT_HOME_CHANNEL_NAME: { label: 'QQ home channel name' },
|
||||||
|
SLACK_BOT_TOKEN: {
|
||||||
|
label: 'Slack bot token',
|
||||||
|
help: 'Use the bot token from OAuth & Permissions after installing your Slack app.',
|
||||||
|
placeholder: 'Paste Slack bot token'
|
||||||
|
},
|
||||||
|
SLACK_APP_TOKEN: {
|
||||||
|
label: 'Slack app token',
|
||||||
|
help: 'Use the app-level token required for Socket Mode.',
|
||||||
|
placeholder: 'Paste Slack app token'
|
||||||
|
},
|
||||||
|
SLACK_ALLOWED_USERS: { label: 'Allowed Slack user IDs', help: 'Recommended. Comma-separated Slack user IDs.' },
|
||||||
|
MATTERMOST_URL: { label: 'Server URL', placeholder: 'https://mattermost.example.com' },
|
||||||
|
MATTERMOST_TOKEN: { label: 'Bot token' },
|
||||||
|
MATTERMOST_ALLOWED_USERS: { label: 'Allowed user IDs', help: 'Recommended. Comma-separated Mattermost user IDs.' },
|
||||||
|
MATRIX_HOMESERVER: { label: 'Homeserver URL', placeholder: 'https://matrix.org' },
|
||||||
|
MATRIX_ACCESS_TOKEN: { label: 'Access token' },
|
||||||
|
MATRIX_USER_ID: { label: 'Bot user ID', placeholder: '@hermes:example.org' },
|
||||||
|
MATRIX_ALLOWED_USERS: {
|
||||||
|
label: 'Allowed Matrix user IDs',
|
||||||
|
help: 'Recommended. Comma-separated user IDs in @user:server format.'
|
||||||
|
},
|
||||||
|
SIGNAL_HTTP_URL: {
|
||||||
|
label: 'Signal bridge URL',
|
||||||
|
placeholder: 'http://127.0.0.1:8080',
|
||||||
|
help: 'URL of a running signal-cli REST bridge.'
|
||||||
|
},
|
||||||
|
SIGNAL_ACCOUNT: { label: 'Phone number', help: 'The number registered with your signal-cli bridge.' },
|
||||||
|
SIGNAL_ALLOWED_USERS: { label: 'Allowed Signal users', help: 'Recommended. Comma-separated Signal identifiers.' },
|
||||||
|
WHATSAPP_ENABLED: {
|
||||||
|
label: 'Enable WhatsApp bridge',
|
||||||
|
help: 'Set automatically by the toggle below. Leave alone unless you know you need it.'
|
||||||
|
},
|
||||||
|
WHATSAPP_MODE: { label: 'Bridge mode' },
|
||||||
|
WHATSAPP_ALLOWED_USERS: {
|
||||||
|
label: 'Allowed WhatsApp users',
|
||||||
|
help: 'Recommended. Comma-separated phone numbers or WhatsApp IDs.'
|
||||||
|
}
|
||||||
|
},
|
||||||
platformIntro: {}
|
platformIntro: {}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -382,6 +710,15 @@ export const en: Translations = {
|
|||||||
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
count: count => `${count} ${count === 1 ? 'profile' : 'profiles'}`,
|
||||||
loading: 'Loading profiles...',
|
loading: 'Loading profiles...',
|
||||||
newProfile: 'New profile',
|
newProfile: 'New profile',
|
||||||
|
allProfiles: 'All profiles',
|
||||||
|
showAllProfiles: 'Show all profiles',
|
||||||
|
switchToProfile: name => `Switch to ${name}`,
|
||||||
|
manageProfiles: 'Manage profiles...',
|
||||||
|
actionsFor: name => `Actions for ${name}`,
|
||||||
|
color: 'Color...',
|
||||||
|
colorFor: name => `Color for ${name}`,
|
||||||
|
setColor: color => `Set color ${color}`,
|
||||||
|
autoColor: 'Auto',
|
||||||
noProfiles: 'No profiles yet.',
|
noProfiles: 'No profiles yet.',
|
||||||
selectPrompt: 'Select a profile to view its details.',
|
selectPrompt: 'Select a profile to view its details.',
|
||||||
refresh: 'Refresh profiles',
|
refresh: 'Refresh profiles',
|
||||||
@ -397,6 +734,10 @@ export const en: Translations = {
|
|||||||
skillsLabel: 'Skills',
|
skillsLabel: 'Skills',
|
||||||
notSet: 'Not set',
|
notSet: 'Not set',
|
||||||
soulDesc: 'The system prompt and persona instructions baked into this profile.',
|
soulDesc: 'The system prompt and persona instructions baked into this profile.',
|
||||||
|
soulOptional: 'optional',
|
||||||
|
soulPlaceholder: mode => `The system prompt / persona for this profile.\nLeave blank to keep the ${mode} default.`,
|
||||||
|
soulPlaceholderCloned: 'cloned',
|
||||||
|
soulPlaceholderEmpty: 'empty',
|
||||||
unsavedChanges: 'Unsaved changes',
|
unsavedChanges: 'Unsaved changes',
|
||||||
loadingSoul: 'Loading SOUL.md...',
|
loadingSoul: 'Loading SOUL.md...',
|
||||||
emptySoul: 'Empty SOUL.md — start writing the persona...',
|
emptySoul: 'Empty SOUL.md — start writing the persona...',
|
||||||
@ -632,6 +973,7 @@ export const en: Translations = {
|
|||||||
|
|
||||||
composer: {
|
composer: {
|
||||||
message: 'Message',
|
message: 'Message',
|
||||||
|
wakingProfile: profile => `Waking up ${profile}…`,
|
||||||
placeholderStarting: 'Starting Hermes...',
|
placeholderStarting: 'Starting Hermes...',
|
||||||
placeholderReconnecting: 'Reconnecting to Hermes…',
|
placeholderReconnecting: 'Reconnecting to Hermes…',
|
||||||
placeholderFollowUp: 'Send follow-up',
|
placeholderFollowUp: 'Send follow-up',
|
||||||
@ -703,6 +1045,7 @@ export const en: Translations = {
|
|||||||
emptyTurn: 'Empty turn',
|
emptyTurn: 'Empty turn',
|
||||||
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
attachments: count => `${count} attachment${count === 1 ? '' : 's'}`,
|
||||||
editingInComposer: 'Editing in composer',
|
editingInComposer: 'Editing in composer',
|
||||||
|
editingQueuedInComposer: 'Editing queued turn in composer',
|
||||||
editQueued: 'Edit queued turn',
|
editQueued: 'Edit queued turn',
|
||||||
sendQueuedNext: 'Send queued turn next',
|
sendQueuedNext: 'Send queued turn next',
|
||||||
sendQueuedNow: 'Send queued turn now',
|
sendQueuedNow: 'Send queued turn now',
|
||||||
@ -730,6 +1073,8 @@ export const en: Translations = {
|
|||||||
tipPost: ' to reference files inline.',
|
tipPost: ' to reference files inline.',
|
||||||
snippetsTitle: 'Prompt snippets',
|
snippetsTitle: 'Prompt snippets',
|
||||||
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
|
snippetsDesc: 'Pick a starter prompt to drop into the composer.',
|
||||||
|
dropFiles: 'Drop files to attach',
|
||||||
|
dropSession: 'Drop to link this chat',
|
||||||
snippets: {
|
snippets: {
|
||||||
codeReview: {
|
codeReview: {
|
||||||
label: 'Code review',
|
label: 'Code review',
|
||||||
@ -747,5 +1092,533 @@ export const en: Translations = {
|
|||||||
text: 'Please explain how this works and point me to the key files.'
|
text: 'Please explain how this works and point me to the key files.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updates: {
|
||||||
|
stages: {
|
||||||
|
idle: 'Getting ready…',
|
||||||
|
prepare: 'Getting ready…',
|
||||||
|
fetch: 'Downloading…',
|
||||||
|
pull: 'Almost there…',
|
||||||
|
pydeps: 'Finishing up…',
|
||||||
|
restart: 'Restarting Hermes…',
|
||||||
|
manual: 'Update from your terminal',
|
||||||
|
error: 'Update paused'
|
||||||
|
},
|
||||||
|
checking: 'Looking for updates…',
|
||||||
|
checkFailedTitle: 'Couldn’t check for updates',
|
||||||
|
tryAgain: 'Try again',
|
||||||
|
notAvailableTitle: 'Update not available',
|
||||||
|
unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
|
||||||
|
connectionRetry: 'Check your connection and try again.',
|
||||||
|
latestBody: 'You’re running the latest version.',
|
||||||
|
allSetTitle: 'You’re all set',
|
||||||
|
availableTitle: 'New update available',
|
||||||
|
availableBody: 'A new version of Hermes is ready to install.',
|
||||||
|
updateNow: 'Update now',
|
||||||
|
maybeLater: 'Maybe later',
|
||||||
|
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
|
||||||
|
manualTitle: 'Update from your terminal',
|
||||||
|
manualBody: 'You installed Hermes from the command line, so updates run there too. Paste this into your terminal:',
|
||||||
|
manualPickedUp: 'Hermes will pick up the new version next time you launch it.',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
done: 'Done',
|
||||||
|
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.',
|
||||||
|
applyingClose: 'Hermes will close to apply the update.',
|
||||||
|
errorTitle: 'Update didn’t finish',
|
||||||
|
errorBody: 'No worries — nothing was lost. You can try again now.',
|
||||||
|
notNow: 'Not now'
|
||||||
|
},
|
||||||
|
|
||||||
|
install: {
|
||||||
|
stageStates: {
|
||||||
|
pending: 'Pending',
|
||||||
|
running: 'Installing',
|
||||||
|
succeeded: 'Done',
|
||||||
|
skipped: 'Skipped',
|
||||||
|
failed: 'Failed'
|
||||||
|
},
|
||||||
|
oneTimeTitle: 'Hermes needs a one-time install',
|
||||||
|
unsupportedDesc: platform =>
|
||||||
|
`Automated first-launch install isn’t available on ${platform} yet. Open Terminal and run the command below, then relaunch this app. Subsequent launches will skip this step.`,
|
||||||
|
installCommand: 'Install command',
|
||||||
|
copyCommand: 'Copy command',
|
||||||
|
viewDocs: 'View install docs',
|
||||||
|
installTo: 'Will install to',
|
||||||
|
retryAfterRun: 'I’ve run it -- retry',
|
||||||
|
failedTitle: 'Installation failed',
|
||||||
|
settingUpTitle: 'Setting up Hermes Agent',
|
||||||
|
finishingTitle: 'Finishing up',
|
||||||
|
failedDesc:
|
||||||
|
'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.',
|
||||||
|
activeDesc:
|
||||||
|
'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.',
|
||||||
|
progress: (completed, total) => `${completed} of ${total} steps complete`,
|
||||||
|
currentStage: stage => ` -- now: ${stage}`,
|
||||||
|
fetchingManifest: 'Fetching installer manifest...',
|
||||||
|
error: 'Error',
|
||||||
|
hideOutput: 'Hide installer output',
|
||||||
|
showOutput: 'Show installer output',
|
||||||
|
lines: count => `${count} line${count === 1 ? '' : 's'}`,
|
||||||
|
noOutput: 'No output yet.',
|
||||||
|
cancelling: 'Cancelling...',
|
||||||
|
cancelInstall: 'Cancel install',
|
||||||
|
transcriptSaved: 'Full transcript saved to',
|
||||||
|
copiedOutput: 'Copied!',
|
||||||
|
copyOutput: 'Copy output',
|
||||||
|
reloadRetry: 'Reload and retry'
|
||||||
|
},
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
headerTitle: "Let's get you setup with Hermes Agent",
|
||||||
|
headerDesc: 'Connect a model provider to start chatting. Most options take one click.',
|
||||||
|
preparingInstall: 'Hermes is finishing install. This usually takes under a minute on first run.',
|
||||||
|
starting: 'Starting Hermes…',
|
||||||
|
lookingUpProviders: 'Looking up providers...',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
otherProviders: 'Other providers',
|
||||||
|
haveApiKey: 'I have an API key',
|
||||||
|
chooseLater: "I'll choose a provider later",
|
||||||
|
recommended: 'Recommended',
|
||||||
|
connected: 'Connected',
|
||||||
|
featuredPitch: 'One subscription, 300+ frontier models — the recommended way to run Hermes',
|
||||||
|
openRouterPitch: 'One key, hundreds of models — a solid default',
|
||||||
|
apiKeyOptions: {
|
||||||
|
openrouter: { short: 'one key, many models', description: 'Hosts hundreds of models behind a single key. Good default for new installs.' },
|
||||||
|
openai: { short: 'GPT-class models', description: 'Direct access to OpenAI models.' },
|
||||||
|
gemini: { short: 'Gemini models', description: 'Direct access to Google Gemini models.' },
|
||||||
|
xai: { short: 'Grok models', description: 'Direct access to xAI Grok models.' },
|
||||||
|
local: {
|
||||||
|
short: 'self-hosted',
|
||||||
|
description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backToSignIn: 'Back to sign in',
|
||||||
|
getKey: 'Get a key',
|
||||||
|
replaceCurrent: 'Replace current value',
|
||||||
|
pasteApiKey: 'Paste API key',
|
||||||
|
couldNotSave: 'Could not save credential.',
|
||||||
|
connecting: 'Connecting',
|
||||||
|
update: 'Update',
|
||||||
|
flowSubtitles: {
|
||||||
|
pkce: 'Opens your browser to sign in, then continues here',
|
||||||
|
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||||
|
loopback: 'Opens your browser to sign in — Hermes connects automatically',
|
||||||
|
external: 'Sign in once in your terminal, then come back to chat'
|
||||||
|
},
|
||||||
|
startingSignIn: provider => `Starting sign-in for ${provider}...`,
|
||||||
|
verifyingCode: provider => `Verifying your code with ${provider}...`,
|
||||||
|
connectedProvider: provider => `${provider} connected.`,
|
||||||
|
connectedPicking: provider => `${provider} connected. Picking a default model...`,
|
||||||
|
signInFailed: 'Sign-in failed. Try again.',
|
||||||
|
pickDifferentProvider: 'Pick a different provider',
|
||||||
|
signInWith: provider => `Sign in with ${provider}`,
|
||||||
|
openedBrowser: provider => `We opened ${provider} in your browser.`,
|
||||||
|
authorizeThere: 'Authorize Hermes there.',
|
||||||
|
copyAuthCode: 'Copy the authorization code and paste it below.',
|
||||||
|
pasteAuthCode: 'Paste authorization code',
|
||||||
|
reopenAuthPage: 'Re-open authorization page',
|
||||||
|
autoBrowser: provider =>
|
||||||
|
`We opened ${provider} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to copy or paste.`,
|
||||||
|
reopenSignInPage: 'Re-open sign-in page',
|
||||||
|
waitingAuthorize: 'Waiting for you to authorize...',
|
||||||
|
externalPending: provider =>
|
||||||
|
`${provider} signs in through its own CLI. Run this command in a terminal, then come back and pick "I've signed in":`,
|
||||||
|
signedIn: "I've signed in",
|
||||||
|
deviceCodeOpened: provider => `We opened ${provider} in your browser. Enter this code there:`,
|
||||||
|
reopenVerification: 'Re-open verification page',
|
||||||
|
copy: 'Copy',
|
||||||
|
defaultModel: 'Default model',
|
||||||
|
freeTier: 'Free tier',
|
||||||
|
pro: 'Pro',
|
||||||
|
free: 'Free',
|
||||||
|
price: (input, output) => `${input} in / ${output} out per Mtok`,
|
||||||
|
change: 'Change',
|
||||||
|
startChatting: 'Start chatting',
|
||||||
|
docs: provider => `${provider} docs`
|
||||||
|
},
|
||||||
|
|
||||||
|
modelPicker: {
|
||||||
|
title: 'Switch model',
|
||||||
|
current: 'current:',
|
||||||
|
unknown: '(unknown)',
|
||||||
|
search: 'Filter providers and models...',
|
||||||
|
noModels: 'No models found.',
|
||||||
|
persistGlobalSession: 'Persist globally (otherwise this session only)',
|
||||||
|
persistGlobal: 'Persist globally',
|
||||||
|
addProvider: 'Add provider',
|
||||||
|
loadFailed: 'Could not load models',
|
||||||
|
noAuthenticatedProviders: 'No authenticated providers.',
|
||||||
|
pro: 'Pro',
|
||||||
|
proNeedsSubscription: 'Pro models need a paid Nous subscription.',
|
||||||
|
free: 'Free',
|
||||||
|
freeTier: 'Free tier',
|
||||||
|
priceTitle: 'Input / Output price per million tokens'
|
||||||
|
},
|
||||||
|
|
||||||
|
modelVisibility: {
|
||||||
|
title: 'Models',
|
||||||
|
search: 'Search models',
|
||||||
|
noAuthenticatedProviders: 'No authenticated providers.',
|
||||||
|
addProvider: 'Add provider…'
|
||||||
|
},
|
||||||
|
|
||||||
|
shell: {
|
||||||
|
windowControls: 'Window controls',
|
||||||
|
paneControls: 'Pane controls',
|
||||||
|
appControls: 'App controls',
|
||||||
|
modelMenu: {
|
||||||
|
search: 'Search models',
|
||||||
|
noModels: 'No models found',
|
||||||
|
editModels: 'Edit Models…',
|
||||||
|
fast: 'Fast',
|
||||||
|
medium: 'Med'
|
||||||
|
},
|
||||||
|
modelOptions: {
|
||||||
|
noOptions: 'No options for this model',
|
||||||
|
options: 'Options',
|
||||||
|
thinking: 'Thinking',
|
||||||
|
fast: 'Fast',
|
||||||
|
effort: 'Effort',
|
||||||
|
minimal: 'Minimal',
|
||||||
|
low: 'Low',
|
||||||
|
medium: 'Medium',
|
||||||
|
high: 'High',
|
||||||
|
max: 'Max',
|
||||||
|
updateFailed: 'Model option update failed',
|
||||||
|
fastFailed: 'Fast mode update failed'
|
||||||
|
},
|
||||||
|
gatewayMenu: {
|
||||||
|
gateway: 'Gateway',
|
||||||
|
connected: 'Connected',
|
||||||
|
connecting: 'Connecting',
|
||||||
|
offline: 'Offline',
|
||||||
|
inferenceReady: 'Inference ready',
|
||||||
|
inferenceNotReady: 'Inference not ready',
|
||||||
|
checkingInference: 'Checking inference',
|
||||||
|
disconnected: 'Disconnected',
|
||||||
|
openSystem: 'Open system panel',
|
||||||
|
connection: label => `Connection: ${label}`,
|
||||||
|
recentActivity: 'Recent activity',
|
||||||
|
viewAllLogs: 'View all logs →',
|
||||||
|
messagingPlatforms: 'Messaging platforms'
|
||||||
|
},
|
||||||
|
statusbar: {
|
||||||
|
unknown: 'unknown',
|
||||||
|
restart: 'restart',
|
||||||
|
update: 'update',
|
||||||
|
updateInProgress: 'Update in progress',
|
||||||
|
commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
|
||||||
|
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||||
|
commit: sha => `commit ${sha}`,
|
||||||
|
branch: branch => `branch ${branch}`,
|
||||||
|
closeCommandCenter: 'Close Command Center',
|
||||||
|
openCommandCenter: 'Open Command Center',
|
||||||
|
gateway: 'Gateway',
|
||||||
|
gatewayReady: 'ready',
|
||||||
|
gatewayNeedsSetup: 'needs setup',
|
||||||
|
gatewayChecking: 'checking',
|
||||||
|
gatewayConnecting: 'connecting',
|
||||||
|
gatewayOffline: 'offline',
|
||||||
|
gatewayTitle: 'Hermes inference gateway status',
|
||||||
|
agents: 'Agents',
|
||||||
|
closeAgents: 'Close agents',
|
||||||
|
openAgents: 'Open agents',
|
||||||
|
subagents: count => `${count} subagent${count === 1 ? '' : 's'}`,
|
||||||
|
failed: count => `${count} failed`,
|
||||||
|
running: count => `${count} running`,
|
||||||
|
cron: 'Cron',
|
||||||
|
openCron: 'Open cron jobs',
|
||||||
|
turnRunning: 'Running',
|
||||||
|
currentTurnElapsed: 'Current turn elapsed',
|
||||||
|
contextUsage: 'Context usage',
|
||||||
|
session: 'Session',
|
||||||
|
runtimeSessionElapsed: 'Runtime session elapsed',
|
||||||
|
yoloOn: 'YOLO on — auto-approving dangerous commands. Click to turn off.',
|
||||||
|
yoloOff: 'YOLO off — click to auto-approve dangerous commands.',
|
||||||
|
modelNone: 'none',
|
||||||
|
noModel: 'no model',
|
||||||
|
switchModel: 'Switch model',
|
||||||
|
openModelPicker: 'Open model picker',
|
||||||
|
modelTitle: (provider, model) => `Model · ${provider}: ${model}`,
|
||||||
|
providerModelTitle: (provider, model) => `${provider} · ${model}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rightSidebar: {
|
||||||
|
aria: 'Right sidebar',
|
||||||
|
panelsAria: 'Right sidebar panels',
|
||||||
|
files: 'File system',
|
||||||
|
terminal: 'Terminal',
|
||||||
|
noFolderSelected: 'No folder selected',
|
||||||
|
changeCwdTitle: 'Change working directory',
|
||||||
|
folderTip: cwd => `${cwd} — click to change folder`,
|
||||||
|
openFolder: 'Open folder',
|
||||||
|
refreshTree: 'Refresh tree',
|
||||||
|
collapseAll: 'Collapse all folders',
|
||||||
|
previewUnavailable: 'Preview unavailable',
|
||||||
|
couldNotPreview: path => `Could not preview ${path}`,
|
||||||
|
noProjectTitle: 'No project',
|
||||||
|
noProjectBody: 'Set a working directory from the status bar to browse files.',
|
||||||
|
unreadableTitle: 'Unreadable',
|
||||||
|
unreadableBody: error => `Could not read this folder (${error}).`,
|
||||||
|
emptyTitle: 'Empty',
|
||||||
|
emptyBody: 'This folder is empty.',
|
||||||
|
treeErrorTitle: 'Tree error',
|
||||||
|
treeErrorBody: 'The file tree hit an error rendering this folder.',
|
||||||
|
tryAgain: 'Try again',
|
||||||
|
loadingTree: 'Loading file tree',
|
||||||
|
loadingFiles: 'Loading files',
|
||||||
|
terminalFocus: 'Focus terminal view',
|
||||||
|
terminalSplit: 'Return to split view',
|
||||||
|
addToChat: 'Add to chat'
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: {
|
||||||
|
tab: 'Preview',
|
||||||
|
closeTab: label => `Close ${label}`,
|
||||||
|
closePane: 'Close preview pane',
|
||||||
|
loading: 'Loading preview',
|
||||||
|
unavailable: 'Preview unavailable',
|
||||||
|
opening: 'Opening...',
|
||||||
|
hide: 'Hide',
|
||||||
|
openPreview: 'Open preview',
|
||||||
|
sourceLineTitle: 'Click to select · shift-click to extend · drag to composer',
|
||||||
|
source: 'SOURCE',
|
||||||
|
renderedPreview: 'PREVIEW',
|
||||||
|
unknownSize: 'unknown size',
|
||||||
|
binaryTitle: 'This looks like a binary file',
|
||||||
|
binaryBody: label => `Previewing ${label} may show unreadable text.`,
|
||||||
|
largeTitle: 'This file is large',
|
||||||
|
largeBody: (label, size) => `${label} is ${size}. Hermes will only show the first 512 KB.`,
|
||||||
|
previewAnyway: 'Preview anyway',
|
||||||
|
truncated: 'Showing first 512 KB.',
|
||||||
|
noInlineTitle: 'No inline preview',
|
||||||
|
noInlineBody: mimeType => `${mimeType || 'This file type'} can still be attached as context.`,
|
||||||
|
console: {
|
||||||
|
deselect: 'Deselect entry',
|
||||||
|
select: 'Select entry',
|
||||||
|
copyFailed: 'Could not copy console output',
|
||||||
|
copyEntry: 'Copy this entry',
|
||||||
|
sendEntry: 'Send this entry to chat',
|
||||||
|
messages: count => `${count} console messages`,
|
||||||
|
resize: 'Resize preview console',
|
||||||
|
title: 'Preview Console',
|
||||||
|
selected: count => `${count} selected`,
|
||||||
|
sendToChat: 'Send to chat',
|
||||||
|
copySelected: 'Copy selected to clipboard',
|
||||||
|
copyAll: 'Copy all to clipboard',
|
||||||
|
copy: 'Copy',
|
||||||
|
clear: 'Clear',
|
||||||
|
empty: 'No console messages yet.',
|
||||||
|
promptHeader: 'Preview console:',
|
||||||
|
sentTitle: 'Sent to chat',
|
||||||
|
sentMessage: count => `${count} log entr${count === 1 ? 'y' : 'ies'} added to composer`
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
appFailedToBoot: 'Preview app failed to boot',
|
||||||
|
serverNotFound: 'Server not found',
|
||||||
|
failedToLoad: 'Preview failed to load',
|
||||||
|
tryAgain: 'Try again',
|
||||||
|
restarting: 'Hermes is restarting...',
|
||||||
|
askRestart: 'Ask Hermes to restart the server',
|
||||||
|
lookingRestart: taskId => `Hermes is looking for a preview server to restart (${taskId})`,
|
||||||
|
restartingTitle: 'Restarting preview server',
|
||||||
|
restartingMessage: 'Hermes is working in the background. Watch the preview console for progress.',
|
||||||
|
startRestartFailed: message => `Could not start server restart: ${message}`,
|
||||||
|
restartFailed: 'Server restart failed',
|
||||||
|
hideConsole: 'Hide preview console',
|
||||||
|
showConsole: 'Show preview console',
|
||||||
|
hideDevTools: 'Hide preview DevTools',
|
||||||
|
openDevTools: 'Open preview DevTools',
|
||||||
|
finishedRestarting: message =>
|
||||||
|
`Hermes finished restarting the preview server${message ? `: ${message}` : ''}`,
|
||||||
|
failedRestarting: message => `Server restart failed: ${message}`,
|
||||||
|
unknownError: 'unknown error',
|
||||||
|
restartedTitle: 'Preview server restarted',
|
||||||
|
reloadingNow: 'Reloading the preview now.',
|
||||||
|
restartFailedTitle: 'Preview restart failed',
|
||||||
|
restartFailedMessage: 'Hermes could not restart the server.',
|
||||||
|
stillWorking:
|
||||||
|
'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.',
|
||||||
|
workspaceReloading: 'Workspace changed, reloading preview',
|
||||||
|
fileChanged: url => `File changed, reloading preview: ${url}`,
|
||||||
|
filesChanged: (count, url) => `${count} file changes, reloading preview: ${url}`,
|
||||||
|
watchFailed: message => `Could not watch preview file: ${message}`,
|
||||||
|
moduleMimeDescription:
|
||||||
|
'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
|
||||||
|
loadFailedConsole: (code, message) => `Load failed${code ? ` (${code})` : ''}: ${message}`,
|
||||||
|
unreachableDescription: 'The preview page could not be reached.',
|
||||||
|
openTarget: url => `Open ${url}`,
|
||||||
|
fallbackTitle: 'Preview'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
assistant: {
|
||||||
|
thread: {
|
||||||
|
loadingSession: 'Loading session',
|
||||||
|
loadingResponse: 'Hermes is loading a response',
|
||||||
|
thinking: 'Thinking',
|
||||||
|
today: time => `Today, ${time}`,
|
||||||
|
yesterday: time => `Yesterday, ${time}`,
|
||||||
|
copy: 'Copy',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
moreActions: 'More actions',
|
||||||
|
branchNewChat: 'Branch in new chat',
|
||||||
|
readAloudFailed: 'Read aloud failed',
|
||||||
|
preparingAudio: 'Preparing audio...',
|
||||||
|
stopReading: 'Stop reading',
|
||||||
|
readAloud: 'Read aloud',
|
||||||
|
editMessage: 'Edit message',
|
||||||
|
stop: 'Stop',
|
||||||
|
editableCheckpoint: 'Editable checkpoint',
|
||||||
|
restorePrevious: 'Restore previous checkpoint',
|
||||||
|
restoreCheckpoint: 'Restore checkpoint',
|
||||||
|
restoreNext: 'Restore next checkpoint',
|
||||||
|
goForward: 'Go forward',
|
||||||
|
sendEdited: 'Send edited message'
|
||||||
|
},
|
||||||
|
approval: {
|
||||||
|
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||||
|
sendFailed: 'Could not send approval response',
|
||||||
|
run: 'Run',
|
||||||
|
moreOptions: 'More approval options',
|
||||||
|
allowSession: 'Allow this session',
|
||||||
|
alwaysAllowMenu: 'Always allow…',
|
||||||
|
reject: 'Reject',
|
||||||
|
alwaysTitle: 'Always allow this command?',
|
||||||
|
alwaysDescription: pattern =>
|
||||||
|
`This adds the “${pattern}” pattern to your permanent allowlist (~/.hermes/config.yaml). Hermes won’t ask again for commands like this — in this session or any future one.`,
|
||||||
|
alwaysAllow: 'Always allow'
|
||||||
|
},
|
||||||
|
clarify: {
|
||||||
|
notReady: 'Clarify request is not ready yet',
|
||||||
|
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||||
|
sendFailed: 'Could not send clarify response',
|
||||||
|
loadingQuestion: 'Loading question…',
|
||||||
|
other: 'Other (type your answer)',
|
||||||
|
placeholder: 'Type your answer…',
|
||||||
|
shortcut: '⌘/Ctrl + Enter to send',
|
||||||
|
back: 'Back',
|
||||||
|
skip: 'Skip',
|
||||||
|
send: 'Send'
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
code: 'Code',
|
||||||
|
copyCode: 'Copy code',
|
||||||
|
renderingImage: 'Rendering image',
|
||||||
|
copyOutput: 'Copy output',
|
||||||
|
copyCommand: 'Copy command',
|
||||||
|
copyContent: 'Copy content',
|
||||||
|
copyUrl: 'Copy URL',
|
||||||
|
copyResults: 'Copy results',
|
||||||
|
copyQuery: 'Copy query',
|
||||||
|
copyFile: 'Copy file',
|
||||||
|
copyPath: 'Copy path',
|
||||||
|
outputAlt: 'Tool output',
|
||||||
|
rawResponse: 'Raw response',
|
||||||
|
copyActivity: 'Copy activity',
|
||||||
|
recoveredOne: 'Recovered after 1 failed step',
|
||||||
|
recoveredMany: count => `Recovered after ${count} failed steps`,
|
||||||
|
failedOne: '1 step failed',
|
||||||
|
failedMany: count => `${count} steps failed`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prompts: {
|
||||||
|
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||||
|
sudoSendFailed: 'Could not send sudo password',
|
||||||
|
secretSendFailed: 'Could not send secret',
|
||||||
|
sudoTitle: 'Administrator password',
|
||||||
|
sudoDesc: 'Hermes needs your sudo password to run a privileged command. It is sent only to your local agent.',
|
||||||
|
sudoPlaceholder: 'sudo password',
|
||||||
|
secretTitle: 'Secret required',
|
||||||
|
secretDesc: 'Hermes needs a credential to continue.',
|
||||||
|
secretPlaceholder: 'secret value'
|
||||||
|
},
|
||||||
|
|
||||||
|
desktop: {
|
||||||
|
audioReadFailed: 'Could not read recorded audio',
|
||||||
|
sessionUnavailable: 'Session unavailable',
|
||||||
|
createSessionFailed: 'Could not create a new session',
|
||||||
|
promptFailed: 'Prompt failed',
|
||||||
|
providerCredentialRequired: 'Add a provider credential before sending your first message.',
|
||||||
|
emptySlashCommand: 'empty slash command',
|
||||||
|
desktopCommands: 'Desktop commands',
|
||||||
|
skillCommandsAvailable: count => `${count} skill commands available.`,
|
||||||
|
warningLine: message => `warning: ${message}`,
|
||||||
|
yoloArmed: 'YOLO armed for this chat',
|
||||||
|
yoloOff: 'YOLO off',
|
||||||
|
yoloSystem: active => `YOLO ${active ? 'on' : 'off'} for this session`,
|
||||||
|
yoloTitle: 'YOLO',
|
||||||
|
yoloToggleFailed: 'Could not toggle YOLO',
|
||||||
|
profileStatus: current =>
|
||||||
|
`Profile: ${current}. Use /profile <name> or the "New session" picker to start a chat in another profile.`,
|
||||||
|
unknownProfile: 'Unknown profile',
|
||||||
|
noProfileNamed: (target, available) => `No profile named "${target}". Available: ${available}`,
|
||||||
|
newChatsProfile: name => `New chats will use profile ${name}.`,
|
||||||
|
setProfileFailed: 'Failed to set profile',
|
||||||
|
sttDisabled: 'Speech-to-text is disabled in settings.',
|
||||||
|
stopFailed: 'Stop failed',
|
||||||
|
regenerateFailed: 'Regenerate failed',
|
||||||
|
editFailed: 'Edit failed',
|
||||||
|
resumeFailed: 'Resume failed',
|
||||||
|
nothingToBranch: 'Nothing to branch',
|
||||||
|
branchNeedsChat: 'Start or resume a chat before branching.',
|
||||||
|
sessionBusy: 'Session busy',
|
||||||
|
branchStopCurrent: 'Stop the current turn before branching this chat.',
|
||||||
|
branchNoText: 'This message has no text to branch from.',
|
||||||
|
branchTitle: 'Branch',
|
||||||
|
branchFailed: 'Branch failed',
|
||||||
|
deleteFailed: 'Delete failed',
|
||||||
|
archived: 'Archived',
|
||||||
|
archiveFailed: 'Archive failed',
|
||||||
|
cwdChangeFailed: 'Working directory change failed',
|
||||||
|
cwdStagedTitle: 'Working directory staged',
|
||||||
|
cwdStagedMessage: 'Restart the desktop backend to apply cwd changes to this active session.',
|
||||||
|
modelSwitchFailed: 'Model switch failed',
|
||||||
|
sessionExported: 'Session exported',
|
||||||
|
sessionExportFailed: 'Could not export session',
|
||||||
|
imageSaved: 'Image saved',
|
||||||
|
downloadStarted: 'Download started',
|
||||||
|
restartToUseSaveImage: 'Restart Hermes Desktop to use Save Image.',
|
||||||
|
restartToSaveImages: 'Restart Hermes Desktop to save images',
|
||||||
|
imageDownloadFailed: 'Image download failed',
|
||||||
|
imagePreviewFailed: 'Image preview failed',
|
||||||
|
imageAttach: 'Image attach',
|
||||||
|
imageWriteFailed: 'Failed to write image to disk.',
|
||||||
|
imageAttachFailed: 'Image attach failed',
|
||||||
|
attachImages: 'Attach images',
|
||||||
|
clipboard: 'Clipboard',
|
||||||
|
noClipboardImage: 'No image found in clipboard',
|
||||||
|
clipboardPasteFailed: 'Clipboard paste failed',
|
||||||
|
dropFiles: 'Drop files'
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
genericFailure: 'Something went wrong',
|
||||||
|
boundaryTitle: 'Something broke in the interface',
|
||||||
|
boundaryDesc: 'The view hit an unexpected error. Your chats and settings are safe.',
|
||||||
|
reloadWindow: 'Reload window',
|
||||||
|
openLogs: 'Open logs'
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
search: {
|
||||||
|
clear: 'Clear search'
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
label: 'pagination',
|
||||||
|
previous: 'Prev',
|
||||||
|
previousAria: 'Go to previous page',
|
||||||
|
next: 'Next',
|
||||||
|
nextAria: 'Go to next page'
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: 'Sidebar',
|
||||||
|
description: 'Displays the mobile sidebar.',
|
||||||
|
toggle: 'Toggle Sidebar'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
|
||||||
import { defineLocale } from './define-locale'
|
import { defineLocale } from './define-locale'
|
||||||
|
|
||||||
export const ja = defineLocale({
|
export const ja = defineLocale({
|
||||||
@ -80,102 +82,174 @@ export const ja = defineLocale({
|
|||||||
themeTitle: 'テーマ',
|
themeTitle: 'テーマ',
|
||||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
|
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
|
||||||
},
|
},
|
||||||
fieldLabels: {
|
fieldLabels: defineFieldCopy({
|
||||||
model: 'デフォルトモデル',
|
model: 'デフォルトモデル',
|
||||||
model_context_length: 'コンテキストウィンドウ',
|
model_context_length: 'コンテキストウィンドウ',
|
||||||
fallback_providers: 'フォールバックモデル',
|
fallback_providers: 'フォールバックモデル',
|
||||||
toolsets: '有効なツールセット',
|
toolsets: '有効なツールセット',
|
||||||
timezone: 'タイムゾーン',
|
timezone: 'タイムゾーン',
|
||||||
'display.personality': '人格',
|
display: {
|
||||||
'display.show_reasoning': '推論ブロック',
|
personality: '人格',
|
||||||
'agent.max_turns': '最大エージェントステップ',
|
show_reasoning: '推論ブロック'
|
||||||
'agent.image_input_mode': '画像添付',
|
},
|
||||||
'terminal.cwd': '作業ディレクトリ',
|
agent: {
|
||||||
'terminal.backend': '実行バックエンド',
|
max_turns: '最大エージェントステップ',
|
||||||
'terminal.timeout': 'コマンドタイムアウト',
|
image_input_mode: '画像添付',
|
||||||
'terminal.persistent_shell': '永続シェル',
|
api_max_retries: 'API 再試行回数',
|
||||||
'terminal.env_passthrough': '環境変数の引き継ぎ',
|
service_tier: 'サービス階層',
|
||||||
|
tool_use_enforcement: 'ツール使用の強制'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
cwd: '作業ディレクトリ',
|
||||||
|
backend: '実行バックエンド',
|
||||||
|
timeout: 'コマンドタイムアウト',
|
||||||
|
persistent_shell: '永続シェル',
|
||||||
|
env_passthrough: '環境変数の引き継ぎ'
|
||||||
|
},
|
||||||
file_read_max_chars: 'ファイル読み取り上限',
|
file_read_max_chars: 'ファイル読み取り上限',
|
||||||
'tool_output.max_bytes': 'ターミナル出力上限',
|
tool_output: {
|
||||||
'tool_output.max_lines': 'ファイルページ上限',
|
max_bytes: 'ターミナル出力上限',
|
||||||
'tool_output.max_line_length': '行長上限',
|
max_lines: 'ファイルページ上限',
|
||||||
'code_execution.mode': 'コード実行モード',
|
max_line_length: '行長上限'
|
||||||
'approvals.mode': '承認モード',
|
},
|
||||||
'approvals.timeout': '承認タイムアウト',
|
code_execution: {
|
||||||
'approvals.mcp_reload_confirm': 'MCP 再読み込みの確認',
|
mode: 'コード実行モード'
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
mode: '承認モード',
|
||||||
|
timeout: '承認タイムアウト',
|
||||||
|
mcp_reload_confirm: 'MCP 再読み込みの確認'
|
||||||
|
},
|
||||||
command_allowlist: 'コマンド許可リスト',
|
command_allowlist: 'コマンド許可リスト',
|
||||||
'security.redact_secrets': 'シークレットを伏せる',
|
security: {
|
||||||
'security.allow_private_urls': 'プライベート URL を許可',
|
redact_secrets: 'シークレットを伏せる',
|
||||||
'browser.allow_private_urls': 'ブラウザーのプライベート URL',
|
allow_private_urls: 'プライベート URL を許可'
|
||||||
'browser.auto_local_for_private_urls': 'プライベート URL にはローカルブラウザーを使用',
|
},
|
||||||
'checkpoints.enabled': 'ファイルチェックポイント',
|
browser: {
|
||||||
'checkpoints.max_snapshots': 'チェックポイント上限',
|
allow_private_urls: 'ブラウザーのプライベート URL',
|
||||||
'voice.record_key': '音声ショートカット',
|
auto_local_for_private_urls: 'プライベート URL にはローカルブラウザーを使用'
|
||||||
'voice.max_recording_seconds': '最大録音時間',
|
},
|
||||||
'voice.auto_tts': '応答を読み上げる',
|
checkpoints: {
|
||||||
'stt.enabled': '音声認識',
|
enabled: 'ファイルチェックポイント',
|
||||||
'stt.provider': '音声認識プロバイダー',
|
max_snapshots: 'チェックポイント上限'
|
||||||
'stt.local.model': 'ローカル文字起こしモデル',
|
},
|
||||||
'stt.local.language': '文字起こし言語',
|
voice: {
|
||||||
'stt.elevenlabs.model_id': 'ElevenLabs STT モデル',
|
record_key: '音声ショートカット',
|
||||||
'stt.elevenlabs.language_code': 'ElevenLabs 言語',
|
max_recording_seconds: '最大録音時間',
|
||||||
'stt.elevenlabs.tag_audio_events': '音声イベントをタグ付け',
|
auto_tts: '応答を読み上げる'
|
||||||
'stt.elevenlabs.diarize': '話者分離',
|
},
|
||||||
'tts.provider': '音声合成プロバイダー',
|
stt: {
|
||||||
'tts.edge.voice': 'Edge 音声',
|
enabled: '音声認識',
|
||||||
'tts.openai.model': 'OpenAI TTS モデル',
|
provider: '音声認識プロバイダー',
|
||||||
'tts.openai.voice': 'OpenAI 音声',
|
local: {
|
||||||
'tts.elevenlabs.voice_id': 'ElevenLabs 音声',
|
model: 'ローカル文字起こしモデル',
|
||||||
'tts.elevenlabs.model_id': 'ElevenLabs モデル',
|
language: '文字起こし言語'
|
||||||
'memory.memory_enabled': '永続メモリ',
|
},
|
||||||
'memory.user_profile_enabled': 'ユーザープロファイル',
|
elevenlabs: {
|
||||||
'memory.memory_char_limit': 'メモリ予算',
|
model_id: 'ElevenLabs STT モデル',
|
||||||
'memory.user_char_limit': 'プロファイル予算',
|
language_code: 'ElevenLabs 言語',
|
||||||
'memory.provider': 'メモリプロバイダー',
|
tag_audio_events: '音声イベントをタグ付け',
|
||||||
'context.engine': 'コンテキストエンジン',
|
diarize: '話者分離'
|
||||||
'compression.enabled': '自動圧縮',
|
}
|
||||||
'compression.threshold': '圧縮しきい値',
|
},
|
||||||
'compression.target_ratio': '圧縮目標',
|
tts: {
|
||||||
'compression.protect_last_n': '保護する直近メッセージ',
|
provider: '音声合成プロバイダー',
|
||||||
'agent.api_max_retries': 'API 再試行回数',
|
edge: {
|
||||||
'agent.service_tier': 'サービス階層',
|
voice: 'Edge 音声'
|
||||||
'agent.tool_use_enforcement': 'ツール使用の強制',
|
},
|
||||||
'delegation.model': 'サブエージェントモデル',
|
openai: {
|
||||||
'delegation.provider': 'サブエージェントプロバイダー',
|
model: 'OpenAI TTS モデル',
|
||||||
'delegation.max_iterations': 'サブエージェントターン上限',
|
voice: 'OpenAI 音声'
|
||||||
'delegation.max_concurrent_children': '並列サブエージェント',
|
},
|
||||||
'delegation.child_timeout_seconds': 'サブエージェントタイムアウト',
|
elevenlabs: {
|
||||||
'delegation.reasoning_effort': 'サブエージェント推論強度',
|
voice_id: 'ElevenLabs 音声',
|
||||||
'updates.non_interactive_local_changes': 'アプリ内更新時のローカル変更'
|
model_id: 'ElevenLabs モデル'
|
||||||
},
|
}
|
||||||
fieldDescriptions: {
|
},
|
||||||
|
memory: {
|
||||||
|
memory_enabled: '永続メモリ',
|
||||||
|
user_profile_enabled: 'ユーザープロファイル',
|
||||||
|
memory_char_limit: 'メモリ予算',
|
||||||
|
user_char_limit: 'プロファイル予算',
|
||||||
|
provider: 'メモリプロバイダー'
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
engine: 'コンテキストエンジン'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: '自動圧縮',
|
||||||
|
threshold: '圧縮しきい値',
|
||||||
|
target_ratio: '圧縮目標',
|
||||||
|
protect_last_n: '保護する直近メッセージ'
|
||||||
|
},
|
||||||
|
delegation: {
|
||||||
|
model: 'サブエージェントモデル',
|
||||||
|
provider: 'サブエージェントプロバイダー',
|
||||||
|
max_iterations: 'サブエージェントターン上限',
|
||||||
|
max_concurrent_children: '並列サブエージェント',
|
||||||
|
child_timeout_seconds: 'サブエージェントタイムアウト',
|
||||||
|
reasoning_effort: 'サブエージェント推論強度'
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes: 'アプリ内更新時のローカル変更'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
fieldDescriptions: defineFieldCopy({
|
||||||
model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
|
model: 'コンポーザーで別のモデルを選ばない限り、新しいチャットで使用されます。',
|
||||||
model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
|
model_context_length: '0 のままにすると、選択したモデルから検出されたコンテキストウィンドウを使用します。',
|
||||||
fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
|
fallback_providers: 'デフォルトモデルが失敗したときに試す provider:model 形式のバックアップです。',
|
||||||
'display.personality': '新しいセッションのデフォルトのアシスタントスタイルです。',
|
display: {
|
||||||
|
personality: '新しいセッションのデフォルトのアシスタントスタイルです。',
|
||||||
|
show_reasoning: 'バックエンドが推論内容を提供したときに表示します。'
|
||||||
|
},
|
||||||
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
|
timezone: 'Hermes がローカル時刻のコンテキストを必要とするときに使用します。空欄ならシステムのタイムゾーンを使います。',
|
||||||
'display.show_reasoning': 'バックエンドが推論内容を提供したときに表示します。',
|
agent: {
|
||||||
'agent.image_input_mode': '画像添付をモデルへ送る方法を制御します。',
|
image_input_mode: '画像添付をモデルへ送る方法を制御します。',
|
||||||
'terminal.cwd': 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
|
max_turns: 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。'
|
||||||
'code_execution.mode': 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。',
|
},
|
||||||
'terminal.persistent_shell': 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
|
terminal: {
|
||||||
'terminal.env_passthrough': 'ツール実行へ渡す環境変数です。',
|
cwd: 'ツールとターミナル作業のデフォルトプロジェクトフォルダーです。',
|
||||||
|
persistent_shell: 'バックエンドが対応している場合、コマンド間でシェル状態を保持します。',
|
||||||
|
env_passthrough: 'ツール実行へ渡す環境変数です。'
|
||||||
|
},
|
||||||
|
code_execution: {
|
||||||
|
mode: 'コード実行を現在のプロジェクトにどれだけ厳密に制限するかを設定します。'
|
||||||
|
},
|
||||||
file_read_max_chars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
|
file_read_max_chars: 'Hermes が 1 回のファイル読み取りで取得できる最大文字数です。',
|
||||||
'approvals.mode': '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
|
approvals: {
|
||||||
'approvals.timeout': '承認プロンプトがタイムアウトするまで待つ時間です。',
|
mode: '明示的な承認が必要なコマンドを Hermes がどう扱うかを設定します。',
|
||||||
'security.redact_secrets': '検出したシークレットを、可能な限りモデルから見える内容から隠します。',
|
timeout: '承認プロンプトがタイムアウトするまで待つ時間です。'
|
||||||
'checkpoints.enabled': 'ファイル編集前にロールバック用スナップショットを作成します。',
|
},
|
||||||
'memory.memory_enabled': '将来のセッションに役立つ永続メモリを保存します。',
|
security: {
|
||||||
'memory.user_profile_enabled': 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。',
|
redact_secrets: '検出したシークレットを、可能な限りモデルから見える内容から隠します。'
|
||||||
'context.engine': '長い会話がコンテキスト上限に近づいたときの管理戦略です。',
|
},
|
||||||
'compression.enabled': '会話が大きくなったとき、古いコンテキストを要約します。',
|
checkpoints: {
|
||||||
'voice.auto_tts': 'アシスタントの応答を自動で読み上げます。',
|
enabled: 'ファイル編集前にロールバック用スナップショットを作成します。'
|
||||||
'stt.enabled': 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
|
},
|
||||||
'stt.elevenlabs.language_code': '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。',
|
memory: {
|
||||||
'agent.max_turns': 'Hermes が 1 回の実行を停止するまでのツール呼び出しターン上限です。',
|
memory_enabled: '将来のセッションに役立つ永続メモリを保存します。',
|
||||||
'updates.non_interactive_local_changes':
|
user_profile_enabled: 'ユーザーの好みをまとめた簡潔なプロファイルを維持します。'
|
||||||
'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
|
},
|
||||||
},
|
context: {
|
||||||
|
engine: '長い会話がコンテキスト上限に近づいたときの管理戦略です。'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: '会話が大きくなったとき、古いコンテキストを要約します。'
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
auto_tts: 'アシスタントの応答を自動で読み上げます。'
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
enabled: 'ローカルまたはプロバイダーによる音声文字起こしを有効にします。',
|
||||||
|
elevenlabs: {
|
||||||
|
language_code: '任意の ISO-639-3 言語コードです。空欄なら ElevenLabs が自動検出します。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes:
|
||||||
|
'アプリから Hermes 自身を更新するとき、ローカルのソース変更を保持するか破棄するかを選びます。ターミナル更新では常に確認されます。'
|
||||||
|
}
|
||||||
|
}),
|
||||||
about: {
|
about: {
|
||||||
heading: 'Hermes Desktop',
|
heading: 'Hermes Desktop',
|
||||||
version: value => `バージョン ${value}`,
|
version: value => `バージョン ${value}`,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { setRuntimeI18nLocale, translateNow } from './runtime'
|
import { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||||
|
import { zh } from './zh'
|
||||||
|
|
||||||
describe('desktop i18n runtime translator', () => {
|
describe('desktop i18n runtime translator', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -39,6 +40,13 @@ describe('desktop i18n runtime translator', () => {
|
|||||||
expect(translateNow('settings.nav.providerApiKeys')).toBe('API 金鑰')
|
expect(translateNow('settings.nav.providerApiKeys')).toBe('API 金鑰')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps translated settings field copy addressable by schema keys', () => {
|
||||||
|
const field = ['display', 'personality'].join('.')
|
||||||
|
|
||||||
|
expect(zh.settings.fieldLabels[field]).toBe('人格')
|
||||||
|
expect(zh.settings.fieldDescriptions[field]).toBe('新会话的默认助手风格。')
|
||||||
|
})
|
||||||
|
|
||||||
it('falls back to English for untranslated desktop-only keys in partial locales', () => {
|
it('falls back to English for untranslated desktop-only keys in partial locales', () => {
|
||||||
setRuntimeI18nLocale('ja')
|
setRuntimeI18nLocale('ja')
|
||||||
|
|
||||||
|
|||||||
@ -12,16 +12,45 @@ interface ModeOptionCopy {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuxTaskCopy {
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
common: {
|
common: {
|
||||||
|
apply: string
|
||||||
|
back: string
|
||||||
save: string
|
save: string
|
||||||
saving: string
|
saving: string
|
||||||
cancel: string
|
cancel: string
|
||||||
|
change: string
|
||||||
|
choose: string
|
||||||
|
clear: string
|
||||||
close: string
|
close: string
|
||||||
|
collapse: string
|
||||||
confirm: string
|
confirm: string
|
||||||
|
connect: string
|
||||||
|
connecting: string
|
||||||
|
continue: string
|
||||||
|
copied: string
|
||||||
|
copy: string
|
||||||
delete: string
|
delete: string
|
||||||
|
docs: string
|
||||||
|
done: string
|
||||||
|
error: string
|
||||||
|
free: string
|
||||||
|
loading: string
|
||||||
|
notSet: string
|
||||||
refresh: string
|
refresh: string
|
||||||
|
remove: string
|
||||||
|
replace: string
|
||||||
retry: string
|
retry: string
|
||||||
|
run: string
|
||||||
|
send: string
|
||||||
|
set: string
|
||||||
|
skip: string
|
||||||
|
update: string
|
||||||
on: string
|
on: string
|
||||||
off: string
|
off: string
|
||||||
}
|
}
|
||||||
@ -182,6 +211,210 @@ export interface Translations {
|
|||||||
hoursAgo: (count: number) => string
|
hoursAgo: (count: number) => string
|
||||||
daysAgo: (count: number) => string
|
daysAgo: (count: number) => string
|
||||||
}
|
}
|
||||||
|
config: {
|
||||||
|
none: string
|
||||||
|
noneParen: string
|
||||||
|
notSet: string
|
||||||
|
commaSeparated: string
|
||||||
|
loading: string
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDesc: string
|
||||||
|
failedLoad: string
|
||||||
|
autosaveFailed: string
|
||||||
|
imported: string
|
||||||
|
invalidJson: string
|
||||||
|
}
|
||||||
|
credentials: {
|
||||||
|
pasteKey: string
|
||||||
|
pasteLabelKey: (label: string) => string
|
||||||
|
optional: string
|
||||||
|
enterValueFirst: string
|
||||||
|
couldNotSave: string
|
||||||
|
remove: string
|
||||||
|
or: string
|
||||||
|
escToCancel: string
|
||||||
|
getKey: string
|
||||||
|
saving: string
|
||||||
|
}
|
||||||
|
envActions: {
|
||||||
|
actionsFor: (label: string) => string
|
||||||
|
credentialActions: string
|
||||||
|
docs: string
|
||||||
|
hideValue: string
|
||||||
|
revealValue: string
|
||||||
|
replace: string
|
||||||
|
set: string
|
||||||
|
clear: string
|
||||||
|
}
|
||||||
|
gateway: {
|
||||||
|
loading: string
|
||||||
|
unavailableTitle: string
|
||||||
|
unavailableDesc: string
|
||||||
|
title: string
|
||||||
|
envOverride: string
|
||||||
|
intro: string
|
||||||
|
appliesTo: string
|
||||||
|
allProfiles: string
|
||||||
|
defaultConnection: string
|
||||||
|
profileConnection: (profile: string) => string
|
||||||
|
envOverrideTitle: string
|
||||||
|
envOverrideDesc: string
|
||||||
|
localTitle: string
|
||||||
|
localDesc: string
|
||||||
|
remoteTitle: string
|
||||||
|
remoteDesc: string
|
||||||
|
remoteUrlTitle: string
|
||||||
|
remoteUrlDesc: string
|
||||||
|
probing: string
|
||||||
|
probeError: string
|
||||||
|
signedIn: string
|
||||||
|
signIn: string
|
||||||
|
signOut: string
|
||||||
|
signInWith: (provider: string) => string
|
||||||
|
authTitle: string
|
||||||
|
authSignedInPassword: string
|
||||||
|
authSignedInOauth: string
|
||||||
|
authNeedsPassword: string
|
||||||
|
authNeedsOauth: (provider: string) => string
|
||||||
|
tokenTitle: string
|
||||||
|
tokenDesc: string
|
||||||
|
existingToken: (value: string) => string
|
||||||
|
savedToken: string
|
||||||
|
pasteSessionToken: string
|
||||||
|
testRemote: string
|
||||||
|
saveForRestart: string
|
||||||
|
saveAndReconnect: string
|
||||||
|
diagnostics: string
|
||||||
|
diagnosticsDesc: string
|
||||||
|
openLogs: string
|
||||||
|
incompleteTitle: string
|
||||||
|
incompleteSignIn: string
|
||||||
|
incompleteToken: string
|
||||||
|
incompleteSignInTest: string
|
||||||
|
incompleteTokenTest: string
|
||||||
|
enterUrlFirst: string
|
||||||
|
restartingTitle: string
|
||||||
|
savedTitle: string
|
||||||
|
restartingMessage: string
|
||||||
|
savedMessage: string
|
||||||
|
connectedTo: (baseUrl: string, version?: string) => string
|
||||||
|
reachableTitle: string
|
||||||
|
signedOutTitle: string
|
||||||
|
signedOutMessage: string
|
||||||
|
failedLoad: string
|
||||||
|
signInFailed: string
|
||||||
|
signOutFailed: string
|
||||||
|
testFailed: string
|
||||||
|
applyFailed: string
|
||||||
|
saveFailed: string
|
||||||
|
}
|
||||||
|
keys: {
|
||||||
|
loading: string
|
||||||
|
failedLoad: string
|
||||||
|
empty: string
|
||||||
|
}
|
||||||
|
mcp: {
|
||||||
|
loading: string
|
||||||
|
failedLoad: string
|
||||||
|
nameRequiredTitle: string
|
||||||
|
nameRequiredMessage: string
|
||||||
|
objectRequired: string
|
||||||
|
invalidJson: string
|
||||||
|
saveFailed: string
|
||||||
|
removeFailed: string
|
||||||
|
gatewayUnavailableTitle: string
|
||||||
|
gatewayUnavailableMessage: string
|
||||||
|
reloadedTitle: string
|
||||||
|
reloadedMessage: string
|
||||||
|
reloadFailed: string
|
||||||
|
savedTitle: string
|
||||||
|
savedMessage: (name: string) => string
|
||||||
|
newServer: string
|
||||||
|
reload: string
|
||||||
|
reloading: string
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDesc: string
|
||||||
|
disabled: string
|
||||||
|
editServer: string
|
||||||
|
name: string
|
||||||
|
serverJson: string
|
||||||
|
remove: string
|
||||||
|
saveServer: string
|
||||||
|
}
|
||||||
|
model: {
|
||||||
|
loading: string
|
||||||
|
appliesDesc: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
applying: string
|
||||||
|
auxiliaryTitle: string
|
||||||
|
resetAllToMain: string
|
||||||
|
auxiliaryDesc: string
|
||||||
|
setToMain: string
|
||||||
|
change: string
|
||||||
|
autoUseMain: string
|
||||||
|
providerDefault: string
|
||||||
|
tasks: Record<string, AuxTaskCopy>
|
||||||
|
}
|
||||||
|
providers: {
|
||||||
|
connectAccount: string
|
||||||
|
haveApiKey: string
|
||||||
|
intro: string
|
||||||
|
connected: string
|
||||||
|
collapse: string
|
||||||
|
connectAnother: string
|
||||||
|
otherProviders: string
|
||||||
|
noProviderKeys: string
|
||||||
|
loading: string
|
||||||
|
}
|
||||||
|
sessions: {
|
||||||
|
loading: string
|
||||||
|
archivedTitle: string
|
||||||
|
archivedIntro: string
|
||||||
|
emptyArchivedTitle: string
|
||||||
|
emptyArchivedDesc: string
|
||||||
|
unarchive: string
|
||||||
|
deletePermanently: string
|
||||||
|
messages: (count: number) => string
|
||||||
|
restored: string
|
||||||
|
deleteConfirm: (title: string) => string
|
||||||
|
defaultDirTitle: string
|
||||||
|
defaultDirDesc: string
|
||||||
|
defaultDirUpdated: string
|
||||||
|
defaultsTo: (label: string) => string
|
||||||
|
change: string
|
||||||
|
choose: string
|
||||||
|
clear: string
|
||||||
|
notSet: string
|
||||||
|
failedLoad: string
|
||||||
|
unarchiveFailed: string
|
||||||
|
deleteFailed: string
|
||||||
|
updateDirFailed: string
|
||||||
|
clearDirFailed: string
|
||||||
|
}
|
||||||
|
toolsets: {
|
||||||
|
loadingConfig: string
|
||||||
|
savedTitle: string
|
||||||
|
savedMessage: (key: string) => string
|
||||||
|
removedTitle: string
|
||||||
|
removedMessage: (key: string) => string
|
||||||
|
failedSave: (key: string) => string
|
||||||
|
failedRemove: (key: string) => string
|
||||||
|
failedReveal: (key: string) => string
|
||||||
|
removeConfirm: (key: string) => string
|
||||||
|
set: string
|
||||||
|
notSet: string
|
||||||
|
selectedTitle: string
|
||||||
|
selectedMessage: (provider: string) => string
|
||||||
|
failedSelect: (provider: string) => string
|
||||||
|
failedLoad: string
|
||||||
|
noProviderOptions: string
|
||||||
|
noProviders: string
|
||||||
|
ready: string
|
||||||
|
nousIncluded: string
|
||||||
|
noApiKeyRequired: string
|
||||||
|
postSetup: (step: string) => string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
skills: {
|
skills: {
|
||||||
@ -246,7 +479,18 @@ export interface Translations {
|
|||||||
|
|
||||||
commandCenter: {
|
commandCenter: {
|
||||||
close: string
|
close: string
|
||||||
|
paletteTitle: string
|
||||||
|
back: string
|
||||||
searchPlaceholder: string
|
searchPlaceholder: string
|
||||||
|
goTo: string
|
||||||
|
commandCenter: string
|
||||||
|
appearance: string
|
||||||
|
settings: string
|
||||||
|
changeTheme: string
|
||||||
|
changeColorMode: string
|
||||||
|
settingsFields: string
|
||||||
|
mcpServers: string
|
||||||
|
archivedChats: string
|
||||||
sections: Record<'sessions' | 'system' | 'usage', string>
|
sections: Record<'sessions' | 'system' | 'usage', string>
|
||||||
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
|
sectionDescriptions: Record<'sessions' | 'system' | 'usage', string>
|
||||||
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
|
nav: Record<'newChat' | 'settings' | 'skills' | 'messaging' | 'artifacts', { title: string; detail: string }>
|
||||||
@ -342,6 +586,15 @@ export interface Translations {
|
|||||||
count: (count: number) => string
|
count: (count: number) => string
|
||||||
loading: string
|
loading: string
|
||||||
newProfile: string
|
newProfile: string
|
||||||
|
allProfiles: string
|
||||||
|
showAllProfiles: string
|
||||||
|
switchToProfile: (name: string) => string
|
||||||
|
manageProfiles: string
|
||||||
|
actionsFor: (name: string) => string
|
||||||
|
color: string
|
||||||
|
colorFor: (name: string) => string
|
||||||
|
setColor: (color: string) => string
|
||||||
|
autoColor: string
|
||||||
noProfiles: string
|
noProfiles: string
|
||||||
selectPrompt: string
|
selectPrompt: string
|
||||||
refresh: string
|
refresh: string
|
||||||
@ -357,6 +610,10 @@ export interface Translations {
|
|||||||
skillsLabel: string
|
skillsLabel: string
|
||||||
notSet: string
|
notSet: string
|
||||||
soulDesc: string
|
soulDesc: string
|
||||||
|
soulOptional: string
|
||||||
|
soulPlaceholder: (mode: string) => string
|
||||||
|
soulPlaceholderCloned: string
|
||||||
|
soulPlaceholderEmpty: string
|
||||||
unsavedChanges: string
|
unsavedChanges: string
|
||||||
loadingSoul: string
|
loadingSoul: string
|
||||||
emptySoul: string
|
emptySoul: string
|
||||||
@ -547,6 +804,7 @@ export interface Translations {
|
|||||||
|
|
||||||
composer: {
|
composer: {
|
||||||
message: string
|
message: string
|
||||||
|
wakingProfile: (profile: string) => string
|
||||||
placeholderStarting: string
|
placeholderStarting: string
|
||||||
placeholderReconnecting: string
|
placeholderReconnecting: string
|
||||||
placeholderFollowUp: string
|
placeholderFollowUp: string
|
||||||
@ -586,6 +844,7 @@ export interface Translations {
|
|||||||
emptyTurn: string
|
emptyTurn: string
|
||||||
attachments: (count: number) => string
|
attachments: (count: number) => string
|
||||||
editingInComposer: string
|
editingInComposer: string
|
||||||
|
editingQueuedInComposer: string
|
||||||
editQueued: string
|
editQueued: string
|
||||||
sendQueuedNext: string
|
sendQueuedNext: string
|
||||||
sendQueuedNow: string
|
sendQueuedNow: string
|
||||||
@ -614,5 +873,496 @@ export interface Translations {
|
|||||||
snippetsTitle: string
|
snippetsTitle: string
|
||||||
snippetsDesc: string
|
snippetsDesc: string
|
||||||
snippets: Record<string, { label: string; description: string; text: string }>
|
snippets: Record<string, { label: string; description: string; text: string }>
|
||||||
|
dropFiles: string
|
||||||
|
dropSession: string
|
||||||
|
}
|
||||||
|
|
||||||
|
updates: {
|
||||||
|
stages: Record<string, string>
|
||||||
|
checking: string
|
||||||
|
checkFailedTitle: string
|
||||||
|
tryAgain: string
|
||||||
|
notAvailableTitle: string
|
||||||
|
unsupportedMessage: string
|
||||||
|
connectionRetry: string
|
||||||
|
latestBody: string
|
||||||
|
allSetTitle: string
|
||||||
|
availableTitle: string
|
||||||
|
availableBody: string
|
||||||
|
updateNow: string
|
||||||
|
maybeLater: string
|
||||||
|
moreChanges: (count: number) => string
|
||||||
|
manualTitle: string
|
||||||
|
manualBody: string
|
||||||
|
manualPickedUp: string
|
||||||
|
copy: string
|
||||||
|
copied: string
|
||||||
|
done: string
|
||||||
|
applyingBody: string
|
||||||
|
applyingClose: string
|
||||||
|
errorTitle: string
|
||||||
|
errorBody: string
|
||||||
|
notNow: string
|
||||||
|
}
|
||||||
|
|
||||||
|
install: {
|
||||||
|
stageStates: Record<string, string>
|
||||||
|
oneTimeTitle: string
|
||||||
|
unsupportedDesc: (platform: string) => string
|
||||||
|
installCommand: string
|
||||||
|
copyCommand: string
|
||||||
|
viewDocs: string
|
||||||
|
installTo: string
|
||||||
|
retryAfterRun: string
|
||||||
|
failedTitle: string
|
||||||
|
settingUpTitle: string
|
||||||
|
finishingTitle: string
|
||||||
|
failedDesc: string
|
||||||
|
activeDesc: string
|
||||||
|
progress: (completed: number, total: number) => string
|
||||||
|
currentStage: (stage: string) => string
|
||||||
|
fetchingManifest: string
|
||||||
|
error: string
|
||||||
|
hideOutput: string
|
||||||
|
showOutput: string
|
||||||
|
lines: (count: number) => string
|
||||||
|
noOutput: string
|
||||||
|
cancelling: string
|
||||||
|
cancelInstall: string
|
||||||
|
transcriptSaved: string
|
||||||
|
copiedOutput: string
|
||||||
|
copyOutput: string
|
||||||
|
reloadRetry: string
|
||||||
|
}
|
||||||
|
|
||||||
|
onboarding: {
|
||||||
|
headerTitle: string
|
||||||
|
headerDesc: string
|
||||||
|
preparingInstall: string
|
||||||
|
starting: string
|
||||||
|
lookingUpProviders: string
|
||||||
|
collapse: string
|
||||||
|
otherProviders: string
|
||||||
|
haveApiKey: string
|
||||||
|
chooseLater: string
|
||||||
|
recommended: string
|
||||||
|
connected: string
|
||||||
|
featuredPitch: string
|
||||||
|
openRouterPitch: string
|
||||||
|
apiKeyOptions: Record<string, { short: string; description: string }>
|
||||||
|
backToSignIn: string
|
||||||
|
getKey: string
|
||||||
|
replaceCurrent: string
|
||||||
|
pasteApiKey: string
|
||||||
|
couldNotSave: string
|
||||||
|
connecting: string
|
||||||
|
update: string
|
||||||
|
flowSubtitles: Record<string, string>
|
||||||
|
startingSignIn: (provider: string) => string
|
||||||
|
verifyingCode: (provider: string) => string
|
||||||
|
connectedProvider: (provider: string) => string
|
||||||
|
connectedPicking: (provider: string) => string
|
||||||
|
signInFailed: string
|
||||||
|
pickDifferentProvider: string
|
||||||
|
signInWith: (provider: string) => string
|
||||||
|
openedBrowser: (provider: string) => string
|
||||||
|
authorizeThere: string
|
||||||
|
copyAuthCode: string
|
||||||
|
pasteAuthCode: string
|
||||||
|
reopenAuthPage: string
|
||||||
|
autoBrowser: (provider: string) => string
|
||||||
|
reopenSignInPage: string
|
||||||
|
waitingAuthorize: string
|
||||||
|
externalPending: (provider: string) => string
|
||||||
|
signedIn: string
|
||||||
|
deviceCodeOpened: (provider: string) => string
|
||||||
|
reopenVerification: string
|
||||||
|
copy: string
|
||||||
|
defaultModel: string
|
||||||
|
freeTier: string
|
||||||
|
pro: string
|
||||||
|
free: string
|
||||||
|
price: (input: string, output: string) => string
|
||||||
|
change: string
|
||||||
|
startChatting: string
|
||||||
|
docs: (provider: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
modelPicker: {
|
||||||
|
title: string
|
||||||
|
current: string
|
||||||
|
unknown: string
|
||||||
|
search: string
|
||||||
|
noModels: string
|
||||||
|
persistGlobalSession: string
|
||||||
|
persistGlobal: string
|
||||||
|
addProvider: string
|
||||||
|
loadFailed: string
|
||||||
|
noAuthenticatedProviders: string
|
||||||
|
pro: string
|
||||||
|
proNeedsSubscription: string
|
||||||
|
free: string
|
||||||
|
freeTier: string
|
||||||
|
priceTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
modelVisibility: {
|
||||||
|
title: string
|
||||||
|
search: string
|
||||||
|
noAuthenticatedProviders: string
|
||||||
|
addProvider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
shell: {
|
||||||
|
windowControls: string
|
||||||
|
paneControls: string
|
||||||
|
appControls: string
|
||||||
|
modelMenu: {
|
||||||
|
search: string
|
||||||
|
noModels: string
|
||||||
|
editModels: string
|
||||||
|
fast: string
|
||||||
|
medium: string
|
||||||
|
}
|
||||||
|
modelOptions: {
|
||||||
|
noOptions: string
|
||||||
|
options: string
|
||||||
|
thinking: string
|
||||||
|
fast: string
|
||||||
|
effort: string
|
||||||
|
minimal: string
|
||||||
|
low: string
|
||||||
|
medium: string
|
||||||
|
high: string
|
||||||
|
max: string
|
||||||
|
updateFailed: string
|
||||||
|
fastFailed: string
|
||||||
|
}
|
||||||
|
gatewayMenu: {
|
||||||
|
gateway: string
|
||||||
|
connected: string
|
||||||
|
connecting: string
|
||||||
|
offline: string
|
||||||
|
inferenceReady: string
|
||||||
|
inferenceNotReady: string
|
||||||
|
checkingInference: string
|
||||||
|
disconnected: string
|
||||||
|
openSystem: string
|
||||||
|
connection: (label: string) => string
|
||||||
|
recentActivity: string
|
||||||
|
viewAllLogs: string
|
||||||
|
messagingPlatforms: string
|
||||||
|
}
|
||||||
|
statusbar: {
|
||||||
|
unknown: string
|
||||||
|
restart: string
|
||||||
|
update: string
|
||||||
|
updateInProgress: string
|
||||||
|
commitsBehind: (count: number, branch: string) => string
|
||||||
|
desktopVersion: (version: string) => string
|
||||||
|
commit: (sha: string) => string
|
||||||
|
branch: (branch: string) => string
|
||||||
|
closeCommandCenter: string
|
||||||
|
openCommandCenter: string
|
||||||
|
gateway: string
|
||||||
|
gatewayReady: string
|
||||||
|
gatewayNeedsSetup: string
|
||||||
|
gatewayChecking: string
|
||||||
|
gatewayConnecting: string
|
||||||
|
gatewayOffline: string
|
||||||
|
gatewayTitle: string
|
||||||
|
agents: string
|
||||||
|
closeAgents: string
|
||||||
|
openAgents: string
|
||||||
|
subagents: (count: number) => string
|
||||||
|
failed: (count: number) => string
|
||||||
|
running: (count: number) => string
|
||||||
|
cron: string
|
||||||
|
openCron: string
|
||||||
|
turnRunning: string
|
||||||
|
currentTurnElapsed: string
|
||||||
|
contextUsage: string
|
||||||
|
session: string
|
||||||
|
runtimeSessionElapsed: string
|
||||||
|
yoloOn: string
|
||||||
|
yoloOff: string
|
||||||
|
modelNone: string
|
||||||
|
noModel: string
|
||||||
|
switchModel: string
|
||||||
|
openModelPicker: string
|
||||||
|
modelTitle: (provider: string, model: string) => string
|
||||||
|
providerModelTitle: (provider: string, model: string) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rightSidebar: {
|
||||||
|
aria: string
|
||||||
|
panelsAria: string
|
||||||
|
files: string
|
||||||
|
terminal: string
|
||||||
|
noFolderSelected: string
|
||||||
|
changeCwdTitle: string
|
||||||
|
folderTip: (cwd: string) => string
|
||||||
|
openFolder: string
|
||||||
|
refreshTree: string
|
||||||
|
collapseAll: string
|
||||||
|
previewUnavailable: string
|
||||||
|
couldNotPreview: (path: string) => string
|
||||||
|
noProjectTitle: string
|
||||||
|
noProjectBody: string
|
||||||
|
unreadableTitle: string
|
||||||
|
unreadableBody: (error: string) => string
|
||||||
|
emptyTitle: string
|
||||||
|
emptyBody: string
|
||||||
|
treeErrorTitle: string
|
||||||
|
treeErrorBody: string
|
||||||
|
tryAgain: string
|
||||||
|
loadingTree: string
|
||||||
|
loadingFiles: string
|
||||||
|
terminalFocus: string
|
||||||
|
terminalSplit: string
|
||||||
|
addToChat: string
|
||||||
|
}
|
||||||
|
|
||||||
|
preview: {
|
||||||
|
tab: string
|
||||||
|
closeTab: (label: string) => string
|
||||||
|
closePane: string
|
||||||
|
loading: string
|
||||||
|
unavailable: string
|
||||||
|
opening: string
|
||||||
|
hide: string
|
||||||
|
openPreview: string
|
||||||
|
sourceLineTitle: string
|
||||||
|
source: string
|
||||||
|
renderedPreview: string
|
||||||
|
unknownSize: string
|
||||||
|
binaryTitle: string
|
||||||
|
binaryBody: (label: string) => string
|
||||||
|
largeTitle: string
|
||||||
|
largeBody: (label: string, size: string) => string
|
||||||
|
previewAnyway: string
|
||||||
|
truncated: string
|
||||||
|
noInlineTitle: string
|
||||||
|
noInlineBody: (mimeType: string) => string
|
||||||
|
console: {
|
||||||
|
deselect: string
|
||||||
|
select: string
|
||||||
|
copyFailed: string
|
||||||
|
copyEntry: string
|
||||||
|
sendEntry: string
|
||||||
|
messages: (count: number) => string
|
||||||
|
resize: string
|
||||||
|
title: string
|
||||||
|
selected: (count: number) => string
|
||||||
|
sendToChat: string
|
||||||
|
copySelected: string
|
||||||
|
copyAll: string
|
||||||
|
copy: string
|
||||||
|
clear: string
|
||||||
|
empty: string
|
||||||
|
promptHeader: string
|
||||||
|
sentTitle: string
|
||||||
|
sentMessage: (count: number) => string
|
||||||
|
}
|
||||||
|
web: {
|
||||||
|
appFailedToBoot: string
|
||||||
|
serverNotFound: string
|
||||||
|
failedToLoad: string
|
||||||
|
tryAgain: string
|
||||||
|
restarting: string
|
||||||
|
askRestart: string
|
||||||
|
lookingRestart: (taskId: string) => string
|
||||||
|
restartingTitle: string
|
||||||
|
restartingMessage: string
|
||||||
|
startRestartFailed: (message: string) => string
|
||||||
|
restartFailed: string
|
||||||
|
hideConsole: string
|
||||||
|
showConsole: string
|
||||||
|
hideDevTools: string
|
||||||
|
openDevTools: string
|
||||||
|
finishedRestarting: (message?: string) => string
|
||||||
|
failedRestarting: (message: string) => string
|
||||||
|
unknownError: string
|
||||||
|
restartedTitle: string
|
||||||
|
reloadingNow: string
|
||||||
|
restartFailedTitle: string
|
||||||
|
restartFailedMessage: string
|
||||||
|
stillWorking: string
|
||||||
|
workspaceReloading: string
|
||||||
|
fileChanged: (url: string) => string
|
||||||
|
filesChanged: (count: number, url: string) => string
|
||||||
|
watchFailed: (message: string) => string
|
||||||
|
moduleMimeDescription: string
|
||||||
|
loadFailedConsole: (code: number | undefined, message: string) => string
|
||||||
|
unreachableDescription: string
|
||||||
|
openTarget: (url: string) => string
|
||||||
|
fallbackTitle: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assistant: {
|
||||||
|
thread: {
|
||||||
|
loadingSession: string
|
||||||
|
loadingResponse: string
|
||||||
|
thinking: string
|
||||||
|
today: (time: string) => string
|
||||||
|
yesterday: (time: string) => string
|
||||||
|
copy: string
|
||||||
|
refresh: string
|
||||||
|
moreActions: string
|
||||||
|
branchNewChat: string
|
||||||
|
readAloudFailed: string
|
||||||
|
preparingAudio: string
|
||||||
|
stopReading: string
|
||||||
|
readAloud: string
|
||||||
|
editMessage: string
|
||||||
|
stop: string
|
||||||
|
editableCheckpoint: string
|
||||||
|
restorePrevious: string
|
||||||
|
restoreCheckpoint: string
|
||||||
|
restoreNext: string
|
||||||
|
goForward: string
|
||||||
|
sendEdited: string
|
||||||
|
}
|
||||||
|
approval: {
|
||||||
|
gatewayDisconnected: string
|
||||||
|
sendFailed: string
|
||||||
|
run: string
|
||||||
|
moreOptions: string
|
||||||
|
allowSession: string
|
||||||
|
alwaysAllowMenu: string
|
||||||
|
reject: string
|
||||||
|
alwaysTitle: string
|
||||||
|
alwaysDescription: (pattern: string) => string
|
||||||
|
alwaysAllow: string
|
||||||
|
}
|
||||||
|
clarify: {
|
||||||
|
notReady: string
|
||||||
|
gatewayDisconnected: string
|
||||||
|
sendFailed: string
|
||||||
|
loadingQuestion: string
|
||||||
|
other: string
|
||||||
|
placeholder: string
|
||||||
|
shortcut: string
|
||||||
|
back: string
|
||||||
|
skip: string
|
||||||
|
send: string
|
||||||
|
}
|
||||||
|
tool: {
|
||||||
|
code: string
|
||||||
|
copyCode: string
|
||||||
|
renderingImage: string
|
||||||
|
copyOutput: string
|
||||||
|
copyCommand: string
|
||||||
|
copyContent: string
|
||||||
|
copyUrl: string
|
||||||
|
copyResults: string
|
||||||
|
copyQuery: string
|
||||||
|
copyFile: string
|
||||||
|
copyPath: string
|
||||||
|
outputAlt: string
|
||||||
|
rawResponse: string
|
||||||
|
copyActivity: string
|
||||||
|
recoveredOne: string
|
||||||
|
recoveredMany: (count: number) => string
|
||||||
|
failedOne: string
|
||||||
|
failedMany: (count: number) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts: {
|
||||||
|
gatewayDisconnected: string
|
||||||
|
sudoSendFailed: string
|
||||||
|
secretSendFailed: string
|
||||||
|
sudoTitle: string
|
||||||
|
sudoDesc: string
|
||||||
|
sudoPlaceholder: string
|
||||||
|
secretTitle: string
|
||||||
|
secretDesc: string
|
||||||
|
secretPlaceholder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop: {
|
||||||
|
audioReadFailed: string
|
||||||
|
sessionUnavailable: string
|
||||||
|
createSessionFailed: string
|
||||||
|
promptFailed: string
|
||||||
|
providerCredentialRequired: string
|
||||||
|
emptySlashCommand: string
|
||||||
|
desktopCommands: string
|
||||||
|
skillCommandsAvailable: (count: number) => string
|
||||||
|
warningLine: (message: string) => string
|
||||||
|
yoloArmed: string
|
||||||
|
yoloOff: string
|
||||||
|
yoloSystem: (active: boolean) => string
|
||||||
|
yoloTitle: string
|
||||||
|
yoloToggleFailed: string
|
||||||
|
profileStatus: (current: string) => string
|
||||||
|
unknownProfile: string
|
||||||
|
noProfileNamed: (target: string, available: string) => string
|
||||||
|
newChatsProfile: (name: string) => string
|
||||||
|
setProfileFailed: string
|
||||||
|
sttDisabled: string
|
||||||
|
stopFailed: string
|
||||||
|
regenerateFailed: string
|
||||||
|
editFailed: string
|
||||||
|
resumeFailed: string
|
||||||
|
nothingToBranch: string
|
||||||
|
branchNeedsChat: string
|
||||||
|
sessionBusy: string
|
||||||
|
branchStopCurrent: string
|
||||||
|
branchNoText: string
|
||||||
|
branchTitle: string
|
||||||
|
branchFailed: string
|
||||||
|
deleteFailed: string
|
||||||
|
archived: string
|
||||||
|
archiveFailed: string
|
||||||
|
cwdChangeFailed: string
|
||||||
|
cwdStagedTitle: string
|
||||||
|
cwdStagedMessage: string
|
||||||
|
modelSwitchFailed: string
|
||||||
|
sessionExported: string
|
||||||
|
sessionExportFailed: string
|
||||||
|
imageSaved: string
|
||||||
|
downloadStarted: string
|
||||||
|
restartToUseSaveImage: string
|
||||||
|
restartToSaveImages: string
|
||||||
|
imageDownloadFailed: string
|
||||||
|
imagePreviewFailed: string
|
||||||
|
imageAttach: string
|
||||||
|
imageWriteFailed: string
|
||||||
|
imageAttachFailed: string
|
||||||
|
attachImages: string
|
||||||
|
clipboard: string
|
||||||
|
noClipboardImage: string
|
||||||
|
clipboardPasteFailed: string
|
||||||
|
dropFiles: string
|
||||||
|
}
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
genericFailure: string
|
||||||
|
boundaryTitle: string
|
||||||
|
boundaryDesc: string
|
||||||
|
reloadWindow: string
|
||||||
|
openLogs: string
|
||||||
|
}
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
search: {
|
||||||
|
clear: string
|
||||||
|
}
|
||||||
|
pagination: {
|
||||||
|
label: string
|
||||||
|
previous: string
|
||||||
|
previousAria: string
|
||||||
|
next: string
|
||||||
|
nextAria: string
|
||||||
|
}
|
||||||
|
sidebar: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
toggle: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||||
|
|
||||||
import { defineLocale } from './define-locale'
|
import { defineLocale } from './define-locale'
|
||||||
|
|
||||||
export const zhHant = defineLocale({
|
export const zhHant = defineLocale({
|
||||||
@ -79,102 +81,174 @@ export const zhHant = defineLocale({
|
|||||||
themeTitle: '主題',
|
themeTitle: '主題',
|
||||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
|
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
|
||||||
},
|
},
|
||||||
fieldLabels: {
|
fieldLabels: defineFieldCopy({
|
||||||
model: '預設模型',
|
model: '預設模型',
|
||||||
model_context_length: '上下文視窗',
|
model_context_length: '上下文視窗',
|
||||||
fallback_providers: '備用模型',
|
fallback_providers: '備用模型',
|
||||||
toolsets: '已啟用工具集',
|
toolsets: '已啟用工具集',
|
||||||
timezone: '時區',
|
timezone: '時區',
|
||||||
'display.personality': '人格',
|
display: {
|
||||||
'display.show_reasoning': '推理區塊',
|
personality: '人格',
|
||||||
'agent.max_turns': '最大代理步數',
|
show_reasoning: '推理區塊'
|
||||||
'agent.image_input_mode': '圖片附件',
|
},
|
||||||
'terminal.cwd': '工作目錄',
|
agent: {
|
||||||
'terminal.backend': '執行後端',
|
max_turns: '最大代理步數',
|
||||||
'terminal.timeout': '指令逾時',
|
image_input_mode: '圖片附件',
|
||||||
'terminal.persistent_shell': '持久化 Shell',
|
api_max_retries: 'API 重試次數',
|
||||||
'terminal.env_passthrough': '環境變數傳遞',
|
service_tier: '服務層級',
|
||||||
|
tool_use_enforcement: '工具使用強制'
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
cwd: '工作目錄',
|
||||||
|
backend: '執行後端',
|
||||||
|
timeout: '指令逾時',
|
||||||
|
persistent_shell: '持久化 Shell',
|
||||||
|
env_passthrough: '環境變數傳遞'
|
||||||
|
},
|
||||||
file_read_max_chars: '檔案讀取上限',
|
file_read_max_chars: '檔案讀取上限',
|
||||||
'tool_output.max_bytes': '終端機輸出上限',
|
tool_output: {
|
||||||
'tool_output.max_lines': '檔案頁面上限',
|
max_bytes: '終端機輸出上限',
|
||||||
'tool_output.max_line_length': '行長上限',
|
max_lines: '檔案頁面上限',
|
||||||
'code_execution.mode': '程式碼執行模式',
|
max_line_length: '行長上限'
|
||||||
'approvals.mode': '批准模式',
|
},
|
||||||
'approvals.timeout': '批准逾時',
|
code_execution: {
|
||||||
'approvals.mcp_reload_confirm': '確認 MCP 重新載入',
|
mode: '程式碼執行模式'
|
||||||
|
},
|
||||||
|
approvals: {
|
||||||
|
mode: '批准模式',
|
||||||
|
timeout: '批准逾時',
|
||||||
|
mcp_reload_confirm: '確認 MCP 重新載入'
|
||||||
|
},
|
||||||
command_allowlist: '指令允許清單',
|
command_allowlist: '指令允許清單',
|
||||||
'security.redact_secrets': '遮蔽密鑰',
|
security: {
|
||||||
'security.allow_private_urls': '允許私有 URL',
|
redact_secrets: '遮蔽密鑰',
|
||||||
'browser.allow_private_urls': '瀏覽器私有 URL',
|
allow_private_urls: '允許私有 URL'
|
||||||
'browser.auto_local_for_private_urls': '私有 URL 使用本機瀏覽器',
|
},
|
||||||
'checkpoints.enabled': '檔案檢查點',
|
browser: {
|
||||||
'checkpoints.max_snapshots': '檢查點上限',
|
allow_private_urls: '瀏覽器私有 URL',
|
||||||
'voice.record_key': '語音快捷鍵',
|
auto_local_for_private_urls: '私有 URL 使用本機瀏覽器'
|
||||||
'voice.max_recording_seconds': '最長錄音時間',
|
},
|
||||||
'voice.auto_tts': '朗讀回覆',
|
checkpoints: {
|
||||||
'stt.enabled': '語音轉文字',
|
enabled: '檔案檢查點',
|
||||||
'stt.provider': '語音轉文字提供方',
|
max_snapshots: '檢查點上限'
|
||||||
'stt.local.model': '本機轉寫模型',
|
},
|
||||||
'stt.local.language': '轉寫語言',
|
voice: {
|
||||||
'stt.elevenlabs.model_id': 'ElevenLabs STT 模型',
|
record_key: '語音快捷鍵',
|
||||||
'stt.elevenlabs.language_code': 'ElevenLabs 語言',
|
max_recording_seconds: '最長錄音時間',
|
||||||
'stt.elevenlabs.tag_audio_events': '標記音訊事件',
|
auto_tts: '朗讀回覆'
|
||||||
'stt.elevenlabs.diarize': '說話者分離',
|
},
|
||||||
'tts.provider': '文字轉語音提供方',
|
stt: {
|
||||||
'tts.edge.voice': 'Edge 語音',
|
enabled: '語音轉文字',
|
||||||
'tts.openai.model': 'OpenAI TTS 模型',
|
provider: '語音轉文字提供方',
|
||||||
'tts.openai.voice': 'OpenAI 語音',
|
local: {
|
||||||
'tts.elevenlabs.voice_id': 'ElevenLabs 語音',
|
model: '本機轉寫模型',
|
||||||
'tts.elevenlabs.model_id': 'ElevenLabs 模型',
|
language: '轉寫語言'
|
||||||
'memory.memory_enabled': '持久記憶',
|
},
|
||||||
'memory.user_profile_enabled': '使用者設定檔',
|
elevenlabs: {
|
||||||
'memory.memory_char_limit': '記憶預算',
|
model_id: 'ElevenLabs STT 模型',
|
||||||
'memory.user_char_limit': '設定檔預算',
|
language_code: 'ElevenLabs 語言',
|
||||||
'memory.provider': '記憶提供方',
|
tag_audio_events: '標記音訊事件',
|
||||||
'context.engine': '上下文引擎',
|
diarize: '說話者分離'
|
||||||
'compression.enabled': '自動壓縮',
|
}
|
||||||
'compression.threshold': '壓縮閾值',
|
},
|
||||||
'compression.target_ratio': '壓縮目標',
|
tts: {
|
||||||
'compression.protect_last_n': '保護最近訊息',
|
provider: '文字轉語音提供方',
|
||||||
'agent.api_max_retries': 'API 重試次數',
|
edge: {
|
||||||
'agent.service_tier': '服務層級',
|
voice: 'Edge 語音'
|
||||||
'agent.tool_use_enforcement': '工具使用強制',
|
},
|
||||||
'delegation.model': '子代理模型',
|
openai: {
|
||||||
'delegation.provider': '子代理提供方',
|
model: 'OpenAI TTS 模型',
|
||||||
'delegation.max_iterations': '子代理輪次上限',
|
voice: 'OpenAI 語音'
|
||||||
'delegation.max_concurrent_children': '平行子代理',
|
},
|
||||||
'delegation.child_timeout_seconds': '子代理逾時',
|
elevenlabs: {
|
||||||
'delegation.reasoning_effort': '子代理推理強度',
|
voice_id: 'ElevenLabs 語音',
|
||||||
'updates.non_interactive_local_changes': '應用程式內更新的本機變更'
|
model_id: 'ElevenLabs 模型'
|
||||||
},
|
}
|
||||||
fieldDescriptions: {
|
},
|
||||||
|
memory: {
|
||||||
|
memory_enabled: '持久記憶',
|
||||||
|
user_profile_enabled: '使用者設定檔',
|
||||||
|
memory_char_limit: '記憶預算',
|
||||||
|
user_char_limit: '設定檔預算',
|
||||||
|
provider: '記憶提供方'
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
engine: '上下文引擎'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: '自動壓縮',
|
||||||
|
threshold: '壓縮閾值',
|
||||||
|
target_ratio: '壓縮目標',
|
||||||
|
protect_last_n: '保護最近訊息'
|
||||||
|
},
|
||||||
|
delegation: {
|
||||||
|
model: '子代理模型',
|
||||||
|
provider: '子代理提供方',
|
||||||
|
max_iterations: '子代理輪次上限',
|
||||||
|
max_concurrent_children: '平行子代理',
|
||||||
|
child_timeout_seconds: '子代理逾時',
|
||||||
|
reasoning_effort: '子代理推理強度'
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes: '應用程式內更新的本機變更'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
fieldDescriptions: defineFieldCopy({
|
||||||
model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
|
model: '除非你在輸入框選擇其他模型,否則新聊天會使用此模型。',
|
||||||
model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
|
model_context_length: '保留 0 會使用所選模型偵測到的上下文視窗。',
|
||||||
fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
|
fallback_providers: '預設模型失敗時要嘗試的備用 provider:model 項目。',
|
||||||
'display.personality': '新工作階段的預設助手風格。',
|
display: {
|
||||||
|
personality: '新工作階段的預設助手風格。',
|
||||||
|
show_reasoning: '後端提供推理內容時顯示該區塊。'
|
||||||
|
},
|
||||||
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
|
timezone: 'Hermes 需要本機時間上下文時使用。留空則使用系統時區。',
|
||||||
'display.show_reasoning': '後端提供推理內容時顯示該區塊。',
|
agent: {
|
||||||
'agent.image_input_mode': '控制圖片附件如何傳送給模型。',
|
image_input_mode: '控制圖片附件如何傳送給模型。',
|
||||||
'terminal.cwd': '工具與終端機操作的預設專案資料夾。',
|
max_turns: 'Hermes 停止一次執行前的工具呼叫輪次上限。'
|
||||||
'code_execution.mode': '程式碼執行被限制在目前專案中的嚴格程度。',
|
},
|
||||||
'terminal.persistent_shell': '後端支援時,在指令之間保留 Shell 狀態。',
|
terminal: {
|
||||||
'terminal.env_passthrough': '傳入工具執行的環境變數。',
|
cwd: '工具與終端機操作的預設專案資料夾。',
|
||||||
|
persistent_shell: '後端支援時,在指令之間保留 Shell 狀態。',
|
||||||
|
env_passthrough: '傳入工具執行的環境變數。'
|
||||||
|
},
|
||||||
|
code_execution: {
|
||||||
|
mode: '程式碼執行被限制在目前專案中的嚴格程度。'
|
||||||
|
},
|
||||||
file_read_max_chars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
|
file_read_max_chars: 'Hermes 單次檔案讀取可讀取的最大字元數。',
|
||||||
'approvals.mode': 'Hermes 如何處理需要明確批准的指令。',
|
approvals: {
|
||||||
'approvals.timeout': '批准提示逾時前等待的時間。',
|
mode: 'Hermes 如何處理需要明確批准的指令。',
|
||||||
'security.redact_secrets': '盡可能從模型可見內容中隱藏偵測到的密鑰。',
|
timeout: '批准提示逾時前等待的時間。'
|
||||||
'checkpoints.enabled': '在檔案編輯前建立可回復的快照。',
|
},
|
||||||
'memory.memory_enabled': '儲存有助於未來工作階段的持久記憶。',
|
security: {
|
||||||
'memory.user_profile_enabled': '維護一份精簡的使用者偏好設定檔。',
|
redact_secrets: '盡可能從模型可見內容中隱藏偵測到的密鑰。'
|
||||||
'context.engine': '長對話接近上下文上限時的管理策略。',
|
},
|
||||||
'compression.enabled': '對話變大時摘要較早的上下文。',
|
checkpoints: {
|
||||||
'voice.auto_tts': '自動朗讀助手回覆。',
|
enabled: '在檔案編輯前建立可回復的快照。'
|
||||||
'stt.enabled': '啟用本機或提供方支援的語音轉寫。',
|
},
|
||||||
'stt.elevenlabs.language_code': '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。',
|
memory: {
|
||||||
'agent.max_turns': 'Hermes 停止一次執行前的工具呼叫輪次上限。',
|
memory_enabled: '儲存有助於未來工作階段的持久記憶。',
|
||||||
'updates.non_interactive_local_changes':
|
user_profile_enabled: '維護一份精簡的使用者偏好設定檔。'
|
||||||
'Hermes 從應用程式內更新自身時,保留本機原始碼變更(stash)或丟棄(discard)。終端機更新一律會詢問。'
|
},
|
||||||
},
|
context: {
|
||||||
|
engine: '長對話接近上下文上限時的管理策略。'
|
||||||
|
},
|
||||||
|
compression: {
|
||||||
|
enabled: '對話變大時摘要較早的上下文。'
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
auto_tts: '自動朗讀助手回覆。'
|
||||||
|
},
|
||||||
|
stt: {
|
||||||
|
enabled: '啟用本機或提供方支援的語音轉寫。',
|
||||||
|
elevenlabs: {
|
||||||
|
language_code: '可選的 ISO-639-3 語言代碼。留空讓 ElevenLabs 自動偵測。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
non_interactive_local_changes:
|
||||||
|
'Hermes 從應用程式內更新自身時,保留本機原始碼變更(stash)或丟棄(discard)。終端機更新一律會詢問。'
|
||||||
|
}
|
||||||
|
}),
|
||||||
about: {
|
about: {
|
||||||
heading: 'Hermes Desktop',
|
heading: 'Hermes Desktop',
|
||||||
version: value => `版本 ${value}`,
|
version: value => `版本 ${value}`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import type { SessionInfo } from '@/hermes'
|
import type { SessionInfo } from '@/hermes'
|
||||||
import { getSessionMessages } from '@/hermes'
|
import { getSessionMessages } from '@/hermes'
|
||||||
|
import { translateNow } from '@/i18n'
|
||||||
import { notify, notifyError } from '@/store/notifications'
|
import { notify, notifyError } from '@/store/notifications'
|
||||||
|
|
||||||
interface ExportSessionParams {
|
interface ExportSessionParams {
|
||||||
@ -49,8 +50,8 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
|
|||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(downloadUrl)
|
URL.revokeObjectURL(downloadUrl)
|
||||||
|
|
||||||
notify({ kind: 'success', message: 'Session exported', durationMs: 2_000 })
|
notify({ kind: 'success', message: translateNow('desktop.sessionExported'), durationMs: 2_000 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err, 'Could not export session')
|
notifyError(err, translateNow('desktop.sessionExportFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user