Expand AITURK Turkish settings and desktop surfaces for beta 5
This commit is contained in:
1 parent
237d1e2060
commit
5e76f58637
108 files changed
+6675
-1034
No files matched your search
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
||||
@@ -13,6 +14,8 @@ const POLL_TIMEOUT_MS = 120_000
|
||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||
// module, so non-OAuth providers render nothing.
|
||||
export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.runtime.memory
|
||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||
@@ -75,8 +78,8 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
||||
await startMemoryProviderOAuth(provider, profile)
|
||||
} catch (err) {
|
||||
setPhase('error')
|
||||
setDetail('Could not start the connection.')
|
||||
notifyError(err, 'Failed to start connection')
|
||||
setDetail(copy.startFailed)
|
||||
notifyError(err, copy.startFailed)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
||||
if (Date.now() > deadline.current) {
|
||||
stop()
|
||||
setPhase('error')
|
||||
setDetail('Timed out — try again.')
|
||||
setDetail(copy.timeout)
|
||||
}
|
||||
|
||||
return
|
||||
@@ -104,7 +107,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
||||
|
||||
if (next.state === 'error') {
|
||||
setPhase('error')
|
||||
setDetail(next.detail || 'Connection failed.')
|
||||
setDetail(next.detail || copy.connectionFailed)
|
||||
} else {
|
||||
setPhase('idle')
|
||||
}
|
||||
@@ -113,7 +116,7 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
||||
}
|
||||
})()
|
||||
}, POLL_MS)
|
||||
}, [profile, provider, stop])
|
||||
}, [profile, provider, stop, copy])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
stop()
|
||||
@@ -124,24 +127,24 @@ export function MemoryConnect({ profile, provider }: { profile?: string; provide
|
||||
return null
|
||||
}
|
||||
|
||||
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
|
||||
const connectLabel = connected ? (auth === 'apikey' ? copy.connectOauth : copy.reconnect) : copy.connect
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{phase === 'idle' && connected && (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Check className="size-3" />
|
||||
{auth === 'apikey' ? 'api key set' : 'oauth set'}
|
||||
{auth === 'apikey' ? copy.apiKeySet : copy.oauthSet}
|
||||
</span>
|
||||
)}
|
||||
{phase === 'pending' ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for browser consent…
|
||||
{copy.waiting}
|
||||
</span>
|
||||
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, Info } from '@/lib/icons'
|
||||
import type { MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
@@ -13,6 +14,8 @@ const FIELD_INPUT = `font-mono ${CONTROL_TEXT} placeholder:text-muted-foreground
|
||||
|
||||
// Field label with an optional info tooltip, shared by the panel and modal rows.
|
||||
export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!field.info) {
|
||||
return <>{field.label}</>
|
||||
}
|
||||
@@ -21,7 +24,7 @@ export function FieldTitle({ field }: { field: MemoryProviderField }) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{field.label}
|
||||
<Tip className="max-w-60 font-normal leading-snug whitespace-normal" label={field.info}>
|
||||
<Info aria-label={`About ${field.label}`} className="size-3.5 text-muted-foreground/70" />
|
||||
<Info aria-label={t.settings.runtime.memory.about(field.label)} className="size-3.5 text-muted-foreground/70" />
|
||||
</Tip>
|
||||
</span>
|
||||
)
|
||||
@@ -41,6 +44,8 @@ export function FieldControl({
|
||||
// controls commit on blur. Absent (the modal), edits stay drafts until Save.
|
||||
onCommit?: (value: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const set = (next: string) => {
|
||||
onChange(next)
|
||||
onCommit?.(next)
|
||||
@@ -103,14 +108,14 @@ export function FieldControl({
|
||||
className={`w-full ${FIELD_INPUT}`}
|
||||
onBlur={commitDraft}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={field.is_set ? 'Leave blank to keep current value' : field.placeholder}
|
||||
placeholder={field.is_set ? t.settings.runtime.memory.keepSecret : field.placeholder}
|
||||
type="password"
|
||||
value={value}
|
||||
/>
|
||||
{field.is_set && (
|
||||
<span className="inline-flex items-center gap-1 self-start font-mono text-[0.65rem] text-(--ui-text-tertiary)">
|
||||
<Check className="size-3 text-(--ui-accent-secondary)" />
|
||||
set
|
||||
{t.settings.runtime.memory.set}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { renderWithEnglish as render } from '../test-locale'
|
||||
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { saveMemoryProviderConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
|
||||
import { ExternalLink, Loader2, Save, SlidersHorizontal } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
@@ -59,6 +61,8 @@ export function ProviderConfigModal({
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}) {
|
||||
const { t, locale } = useI18n()
|
||||
const copy = t.settings.runtime.memory
|
||||
const activeProfile = useStore($activeGatewayProfile)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [seeded, setSeeded] = useState<Record<string, string>>({})
|
||||
@@ -81,11 +85,11 @@ export function ProviderConfigModal({
|
||||
|
||||
try {
|
||||
await saveMemoryProviderConfig(provider, edited, profile)
|
||||
notify({ kind: 'success', title: `${config.label} saved`, message: 'Memory provider configuration updated.' })
|
||||
notify({ kind: 'success', title: copy.saved(config.label), message: copy.updated })
|
||||
await onSaved()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${config.label} settings`)
|
||||
notifyError(err, copy.saveFailed(config.label))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -95,11 +99,8 @@ export function ProviderConfigModal({
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent bodyClassName="dt-portal-scrollbar" className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={SlidersHorizontal}>{config.label} — full configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Every {config.label} option for the <span className="font-medium">{profile ?? activeProfile}</span> profile.
|
||||
Blank fields fall back to the resolved host or built-in default.
|
||||
</DialogDescription>
|
||||
<DialogTitle icon={SlidersHorizontal}>{copy.fullTitle(config.label)}</DialogTitle>
|
||||
<DialogDescription>{copy.fullDescription(config.label, profile ?? activeProfile)}</DialogDescription>
|
||||
{config.docs_url && (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-accent-secondary) underline-offset-4 transition-colors hover:underline"
|
||||
@@ -111,48 +112,50 @@ export function ProviderConfigModal({
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{config.label} configuration reference
|
||||
{copy.reference(config.label)}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-w-0">
|
||||
{groupFields(config.fields).map(([group, fields]) => (
|
||||
<section className="mt-6 first:mt-2" key={group}>
|
||||
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
||||
{group}
|
||||
</h3>
|
||||
<div className="pl-1">
|
||||
{fields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={<FieldTitle field={field} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{groupFields(config.fields.map(field => localizeMemoryField(config.name, field, locale))).map(
|
||||
([group, fields]) => (
|
||||
<section className="mt-6 first:mt-2" key={group}>
|
||||
<h3 className="border-b border-(--ui-accent-secondary)/30 pb-1.5 font-mono text-[0.68rem] uppercase tracking-wide text-(--ui-accent-secondary)">
|
||||
{group === 'Other' ? copy.other : group}
|
||||
</h3>
|
||||
<div className="pl-1">
|
||||
{fields.map(field => (
|
||||
<div className="border-b border-border/40 last:border-b-0" key={field.key}>
|
||||
<ListRow
|
||||
action={
|
||||
<FieldControl
|
||||
field={field}
|
||||
onChange={value => setValues(current => ({ ...current, [field.key]: value }))}
|
||||
value={values[field.key] ?? ''}
|
||||
/>
|
||||
}
|
||||
description={field.description}
|
||||
title={<FieldTitle field={field} />}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm" type="button" variant="ghost">
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button disabled={saving} onClick={() => void save()} size="sm">
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save changes
|
||||
{copy.saveChanges}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render as renderLocale, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
import type { MemoryProviderConfig } from '@/types/hermes'
|
||||
|
||||
import { renderWithEnglish as render } from '../test-locale'
|
||||
|
||||
const getMemoryProviderConfig = vi.fn()
|
||||
const saveMemoryProviderConfig = vi.fn()
|
||||
|
||||
@@ -198,6 +201,24 @@ describe('ProviderConfigPanel', () => {
|
||||
expect(await screen.findByDisplayValue('myws')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders Turkish field copy and saves the original backend key and value', async () => {
|
||||
const { ProviderConfigPanel } = await import('./provider-config-panel')
|
||||
renderLocale(
|
||||
<I18nProvider configClient={null} initialLocale="tr">
|
||||
<ProviderConfigPanel provider="honcho" />
|
||||
</I18nProvider>
|
||||
)
|
||||
expect(await screen.findByText('Çalışma alanı')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('Honcho çalışma alanı kimliği. Varsayılan olarak profilin ana bilgisayarını kullanır.')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('combobox').textContent).toContain('Bulut')
|
||||
const workspace = screen.getByDisplayValue('myws')
|
||||
fireEvent.change(workspace, { target: { value: 'ekibim' } })
|
||||
fireEvent.blur(workspace)
|
||||
await waitFor(() => expect(saveMemoryProviderConfig).toHaveBeenCalledWith('honcho', { workspace: 'ekibim' }))
|
||||
})
|
||||
|
||||
it('renders nothing for a provider with no declared config surface', async () => {
|
||||
getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', docs_url: '', fields: [] })
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { localizeMemoryField } from '@/i18n/tr-memory-fields'
|
||||
import { SlidersHorizontal } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
@@ -21,6 +23,8 @@ function seedValues(config: MemoryProviderConfig): Record<string, string> {
|
||||
}
|
||||
|
||||
export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) {
|
||||
const { t, locale } = useI18n()
|
||||
const copy = t.settings.runtime.memory
|
||||
const [config, setConfig] = useState<MemoryProviderConfig | null>(null)
|
||||
const [loadError, setLoadError] = useState<null | string>(null)
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
@@ -38,9 +42,9 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setConfig(null)
|
||||
setLoadError(err instanceof Error ? err.message : 'Memory provider settings failed to load')
|
||||
setLoadError(err instanceof Error ? err.message : copy.loadFailed)
|
||||
}
|
||||
}, [profile, provider])
|
||||
}, [profile, provider, copy.loadFailed])
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(null)
|
||||
@@ -71,10 +75,10 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
||||
setSaved(current => ({ ...current, [field.key]: value }))
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${field.label}`)
|
||||
notifyError(err, copy.saveFailed(field.label))
|
||||
}
|
||||
},
|
||||
[profile, provider, saved]
|
||||
[profile, provider, saved, copy]
|
||||
)
|
||||
|
||||
// Providers without a declared config surface (e.g. builtin) render nothing.
|
||||
@@ -87,20 +91,21 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Memory provider settings failed to load: {loadError}
|
||||
{copy.loadFailed}: {loadError}
|
||||
</span>
|
||||
<Button onClick={() => void refresh()} size="sm" type="button" variant="secondary">
|
||||
Retry
|
||||
{t.common.retry}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
|
||||
return <PageLoader className="min-h-24" label={copy.loading} />
|
||||
}
|
||||
|
||||
const inlineFields = config.fields.filter(field => field.inline)
|
||||
const secretFields = config.fields.filter(field => field.kind === 'secret')
|
||||
const fields = config.fields.map(field => localizeMemoryField(config.name, field, locale))
|
||||
const inlineFields = fields.filter(field => field.inline)
|
||||
const secretFields = fields.filter(field => field.kind === 'secret')
|
||||
const hasFullConfig = config.fields.some(field => !field.inline)
|
||||
|
||||
return (
|
||||
@@ -114,16 +119,16 @@ export function ProviderConfigPanel({ profile, provider }: { profile?: string; p
|
||||
>
|
||||
<DisclosureCaret open={expanded} />
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
||||
{config.label} settings
|
||||
{copy.settings(config.label)}
|
||||
</span>
|
||||
{secretFields.map(field => (
|
||||
<Pill key={field.key}>{field.is_set ? `${field.label} set` : `${field.label} not set`}</Pill>
|
||||
<Pill key={field.key}>{copy.credentialStatus(field.label, field.is_set)}</Pill>
|
||||
))}
|
||||
</button>
|
||||
{hasFullConfig && (
|
||||
<Button onClick={() => setShowModal(true)} size="sm" type="button" variant="secondary">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Full config…
|
||||
{copy.fullConfig}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user