mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Make H5 browser access manageable from desktop settings
Add the desktop-side H5 settings client, store state, and General tab controls so users can opt in, regenerate tokens, manage origins, and copy the browser URL without touching unrelated desktop flows. Constraint: Task 3 is limited to desktop Settings UI and store wiring only Rejected: Expand into AppShell or browser runtime work now | reserved for later tasks in the plan Confidence: high Scope-risk: narrow Directive: Keep H5 fetchAll integration tolerant of missing endpoint responses so existing settings loads do not regress Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx src/stores/settingsStore.test.ts Tested: cd desktop && bun run lint
This commit is contained in:
parent
37bdc4e6e7
commit
a677163e0d
@ -126,6 +126,13 @@ describe('Settings > General tab', () => {
|
||||
skipWebFetchPreflight: true,
|
||||
desktopNotificationsEnabled: true,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
h5Access: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
h5AccessGeneratedToken: null,
|
||||
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
|
||||
useSettingsStore.setState({ thinkingEnabled: enabled })
|
||||
}),
|
||||
@ -138,6 +145,11 @@ describe('Settings > General tab', () => {
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
enableH5Access: vi.fn(),
|
||||
disableH5Access: vi.fn(),
|
||||
regenerateH5AccessToken: vi.fn(),
|
||||
updateH5AccessSettings: vi.fn(),
|
||||
clearH5AccessGeneratedToken: vi.fn(),
|
||||
})
|
||||
|
||||
useUIStore.setState({ pendingSettingsTab: null })
|
||||
@ -237,6 +249,81 @@ describe('Settings > General tab', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the H5 section in a disabled state by default', () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked()
|
||||
expect(within(section).getByText('Disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('enables H5 access from the General settings section', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByLabelText('Enable H5 access'))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('regenerates the H5 token from General settings', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5a1b2c3',
|
||||
allowedOrigins: ['https://phone.example'],
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Regenerate token' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates H5 public URL and allowed origins from General settings', async () => {
|
||||
useSettingsStore.setState({
|
||||
h5Access: {
|
||||
enabled: true,
|
||||
tokenPreview: 'h5a1b2c3',
|
||||
allowedOrigins: ['https://old.example'],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
})
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const section = screen.getByRole('region', { name: 'H5 Access' })
|
||||
fireEvent.change(within(section).getByLabelText('Public URL'), {
|
||||
target: { value: 'https://phone.example/app' },
|
||||
})
|
||||
fireEvent.change(within(section).getByLabelText('Allowed origins'), {
|
||||
target: { value: 'https://phone.example, https://tablet.example' },
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' }))
|
||||
})
|
||||
|
||||
expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({
|
||||
publicBaseUrl: 'https://phone.example/app',
|
||||
allowedOrigins: ['https://phone.example', 'https://tablet.example'],
|
||||
})
|
||||
})
|
||||
|
||||
it('saves WebSearch fallback provider settings', () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
38
desktop/src/api/h5Access.ts
Normal file
38
desktop/src/api/h5Access.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { api } from './client'
|
||||
import type { H5AccessSettings } from '../types/settings'
|
||||
|
||||
export type { H5AccessSettings } from '../types/settings'
|
||||
|
||||
export type H5AccessStatus = {
|
||||
settings: H5AccessSettings
|
||||
}
|
||||
|
||||
export type H5AccessTokenResult = {
|
||||
settings: H5AccessSettings
|
||||
token: string
|
||||
}
|
||||
|
||||
export const h5AccessApi = {
|
||||
get() {
|
||||
return api.get<H5AccessStatus>('/api/h5-access')
|
||||
},
|
||||
|
||||
enable() {
|
||||
return api.post<H5AccessTokenResult>('/api/h5-access/enable')
|
||||
},
|
||||
|
||||
disable() {
|
||||
return api.post<H5AccessStatus>('/api/h5-access/disable')
|
||||
},
|
||||
|
||||
regenerate() {
|
||||
return api.post<H5AccessTokenResult>('/api/h5-access/regenerate')
|
||||
},
|
||||
|
||||
update(input: {
|
||||
allowedOrigins?: string[]
|
||||
publicBaseUrl?: string | null
|
||||
}) {
|
||||
return api.put<H5AccessStatus>('/api/h5-access', input)
|
||||
},
|
||||
}
|
||||
@ -667,6 +667,26 @@ export const en = {
|
||||
'settings.general.notificationsOpenSettings': 'Open Settings',
|
||||
'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled',
|
||||
'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.',
|
||||
'settings.general.h5AccessTitle': 'H5 Access',
|
||||
'settings.general.h5AccessDescription': 'Opt in to browser access from your phone or another device using a generated token and an allowlisted origin set.',
|
||||
'settings.general.h5AccessEnabled': 'Enable H5 access',
|
||||
'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.',
|
||||
'settings.general.h5AccessTokenPreview': 'Token preview',
|
||||
'settings.general.h5AccessDisabledValue': 'Disabled',
|
||||
'settings.general.h5AccessRegenerate': 'Regenerate token',
|
||||
'settings.general.h5AccessGeneratedToken': 'Generated token',
|
||||
'settings.general.h5AccessGeneratedTokenHint': 'Shown once. Copy it now and store it like a password.',
|
||||
'settings.general.h5AccessCopy': 'Copy',
|
||||
'settings.general.h5AccessHideToken': 'Hide token',
|
||||
'settings.general.h5AccessPublicUrl': 'Public URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': 'Allowed origins',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.',
|
||||
'settings.general.h5AccessSave': 'Save H5 settings',
|
||||
'settings.general.h5AccessUrl': 'H5 URL',
|
||||
'settings.general.h5AccessSafetyNote': 'Treat the full token like a password and only allow origins you fully control.',
|
||||
'settings.general.h5AccessError': 'Failed to update H5 access settings.',
|
||||
'settings.general.webFetchPreflightTitle': 'WebFetch Preflight',
|
||||
'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.',
|
||||
'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight',
|
||||
|
||||
@ -669,6 +669,26 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.notificationsOpenSettings': '打开系统设置',
|
||||
'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用',
|
||||
'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。',
|
||||
'settings.general.h5AccessTitle': 'H5 访问',
|
||||
'settings.general.h5AccessDescription': '通过一次性生成的令牌和允许来源列表,为手机或其他设备上的浏览器访问开启可选 H5 模式。',
|
||||
'settings.general.h5AccessEnabled': '启用 H5 访问',
|
||||
'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。',
|
||||
'settings.general.h5AccessTokenPreview': '令牌预览',
|
||||
'settings.general.h5AccessDisabledValue': '未启用',
|
||||
'settings.general.h5AccessRegenerate': '重新生成令牌',
|
||||
'settings.general.h5AccessGeneratedToken': '生成的令牌',
|
||||
'settings.general.h5AccessGeneratedTokenHint': '仅显示一次,请立即复制并像密码一样保存。',
|
||||
'settings.general.h5AccessCopy': '复制',
|
||||
'settings.general.h5AccessHideToken': '隐藏令牌',
|
||||
'settings.general.h5AccessPublicUrl': '公开访问 URL',
|
||||
'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com',
|
||||
'settings.general.h5AccessAllowedOrigins': '允许的来源',
|
||||
'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example',
|
||||
'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。',
|
||||
'settings.general.h5AccessSave': '保存 H5 设置',
|
||||
'settings.general.h5AccessUrl': 'H5 链接',
|
||||
'settings.general.h5AccessSafetyNote': '完整令牌等同于密码,只允许你完全信任和可控的来源。',
|
||||
'settings.general.h5AccessError': '更新 H5 设置失败。',
|
||||
'settings.general.webFetchPreflightTitle': 'WebFetch 预检',
|
||||
'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
||||
'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检',
|
||||
|
||||
@ -5,6 +5,7 @@ import { useTranslation } from '../i18n'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Textarea } from '../components/shared/Textarea'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import { Dropdown } from '../components/shared/Dropdown'
|
||||
import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings'
|
||||
@ -44,6 +45,7 @@ import {
|
||||
restoreSettingsJsonSecrets,
|
||||
stripProviderSettingsJsonEnv,
|
||||
} from '../lib/providerSettingsJson'
|
||||
import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -1355,17 +1357,37 @@ function GeneralSettings() {
|
||||
setDesktopNotificationsEnabled,
|
||||
webSearch,
|
||||
setWebSearch,
|
||||
h5Access,
|
||||
h5AccessGeneratedToken,
|
||||
enableH5Access,
|
||||
disableH5Access,
|
||||
regenerateH5AccessToken,
|
||||
updateH5AccessSettings,
|
||||
clearH5AccessGeneratedToken,
|
||||
} = useSettingsStore()
|
||||
const t = useTranslation()
|
||||
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
|
||||
const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '')
|
||||
const [h5AllowedOriginsDraft, setH5AllowedOriginsDraft] = useState(serializeAllowedOrigins(h5Access.allowedOrigins))
|
||||
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
|
||||
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
|
||||
const [h5ActionRunning, setH5ActionRunning] = useState(false)
|
||||
const [h5Error, setH5Error] = useState<string | null>(null)
|
||||
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
||||
const h5AccessUrl = h5Access.enabled && h5Access.publicBaseUrl ? h5Access.publicBaseUrl : null
|
||||
const h5AccessDirty =
|
||||
h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') ||
|
||||
!arraysEqual(parseAllowedOriginsDraft(h5AllowedOriginsDraft), h5Access.allowedOrigins)
|
||||
|
||||
useEffect(() => {
|
||||
setWebSearchDraft(webSearch)
|
||||
}, [webSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '')
|
||||
setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins))
|
||||
}, [h5Access])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getDesktopNotificationPermission().then((permission) => {
|
||||
@ -1453,6 +1475,42 @@ function GeneralSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const runH5Action = async (action: () => Promise<void>) => {
|
||||
setH5ActionRunning(true)
|
||||
setH5Error(null)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
setH5Error(error instanceof Error ? error.message : t('settings.general.h5AccessError'))
|
||||
} finally {
|
||||
setH5ActionRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleH5AccessToggle = async (enabled: boolean) => {
|
||||
await runH5Action(async () => {
|
||||
if (enabled) {
|
||||
await enableH5Access()
|
||||
return
|
||||
}
|
||||
|
||||
await disableH5Access()
|
||||
})
|
||||
}
|
||||
|
||||
const handleH5SettingsSave = async () => {
|
||||
await runH5Action(async () => {
|
||||
await updateH5AccessSettings({
|
||||
publicBaseUrl: h5PublicBaseUrlDraft.trim() || null,
|
||||
allowedOrigins: parseAllowedOriginsDraft(h5AllowedOriginsDraft),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleH5Copy = async (value: string) => {
|
||||
await copyTextToClipboard(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
{/* Appearance selector */}
|
||||
@ -1580,6 +1638,157 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<section aria-labelledby="general-h5-access-title" role="region">
|
||||
<h2
|
||||
id="general-h5-access-title"
|
||||
className="text-base font-semibold text-[var(--color-text-primary)] mb-1"
|
||||
>
|
||||
{t('settings.general.h5AccessTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">
|
||||
{t('settings.general.h5AccessDescription')}
|
||||
</p>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('settings.general.h5AccessEnabled')}
|
||||
checked={h5Access.enabled}
|
||||
onChange={(event) => void handleH5AccessToggle(event.target.checked)}
|
||||
disabled={h5ActionRunning}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.h5AccessEnabled')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
||||
{t('settings.general.h5AccessEnabledHint')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessTokenPreview')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{h5Access.tokenPreview ?? t('settings.general.h5AccessDisabledValue')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void runH5Action(regenerateH5AccessToken)}
|
||||
disabled={!h5Access.enabled || h5ActionRunning}
|
||||
>
|
||||
{t('settings.general.h5AccessRegenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{h5AccessGeneratedToken && (
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.h5AccessGeneratedToken')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleH5Copy(h5AccessGeneratedToken)}
|
||||
>
|
||||
{t('settings.general.h5AccessCopy')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => clearH5AccessGeneratedToken()}
|
||||
>
|
||||
{t('settings.general.h5AccessHideToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="mt-2 block rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text-primary)] break-all">
|
||||
{h5AccessGeneratedToken}
|
||||
</code>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessGeneratedTokenHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicUrl')}
|
||||
value={h5PublicBaseUrlDraft}
|
||||
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="h5-access-allowed-origins"
|
||||
label={t('settings.general.h5AccessAllowedOrigins')}
|
||||
value={h5AllowedOriginsDraft}
|
||||
placeholder={t('settings.general.h5AccessAllowedOriginsPlaceholder')}
|
||||
onChange={(event) => setH5AllowedOriginsDraft(event.target.value)}
|
||||
className="min-h-[88px]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessOriginsHint')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleH5SettingsSave()}
|
||||
disabled={!h5AccessDirty || h5ActionRunning}
|
||||
aria-label={t('settings.general.h5AccessSave')}
|
||||
>
|
||||
{t('settings.general.h5AccessSave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{h5AccessUrl && (
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessUrl')}
|
||||
</div>
|
||||
<div className="mt-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] break-all">
|
||||
{h5AccessUrl}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
onClick={() => void handleH5Copy(h5AccessUrl)}
|
||||
>
|
||||
{t('settings.general.h5AccessCopy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-xs text-[var(--color-text-tertiary)] leading-5">
|
||||
{t('settings.general.h5AccessSafetyNote')}
|
||||
</p>
|
||||
{h5Error && (
|
||||
<p className="mt-2 text-xs text-[var(--color-error)]">
|
||||
{h5Error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
|
||||
@ -1698,6 +1907,21 @@ function GeneralSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function serializeAllowedOrigins(origins: string[]) {
|
||||
return origins.join(', ')
|
||||
}
|
||||
|
||||
function parseAllowedOriginsDraft(value: string) {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
// ─── Agents Settings ──────────────────────────────────────
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { modelsApi } from '../api/models'
|
||||
import type { PermissionMode, EffortLevel, ModelInfo, ThemeMode, WebSearchSettings } from '../types/settings'
|
||||
import { h5AccessApi } from '../api/h5Access'
|
||||
import type { H5AccessSettings, PermissionMode, EffortLevel, ModelInfo, ThemeMode, WebSearchSettings } from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import { useUIStore } from './uiStore'
|
||||
|
||||
@ -28,10 +29,13 @@ type SettingsStore = {
|
||||
skipWebFetchPreflight: boolean
|
||||
desktopNotificationsEnabled: boolean
|
||||
webSearch: WebSearchSettings
|
||||
h5Access: H5AccessSettings
|
||||
h5AccessGeneratedToken: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchAll: () => Promise<void>
|
||||
fetchH5Access: () => Promise<void>
|
||||
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
||||
setModel: (modelId: string) => Promise<void>
|
||||
setEffort: (level: EffortLevel) => Promise<void>
|
||||
@ -41,6 +45,21 @@ type SettingsStore = {
|
||||
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
||||
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||
enableH5Access: () => Promise<void>
|
||||
disableH5Access: () => Promise<void>
|
||||
regenerateH5AccessToken: () => Promise<void>
|
||||
updateH5AccessSettings: (input: {
|
||||
allowedOrigins?: string[]
|
||||
publicBaseUrl?: string | null
|
||||
}) => Promise<void>
|
||||
clearH5AccessGeneratedToken: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
@ -55,18 +74,21 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
skipWebFetchPreflight: true,
|
||||
desktopNotificationsEnabled: false,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
|
||||
h5AccessGeneratedToken: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchAll: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [{ mode }, modelsRes, { model }, { level }, userSettings] = await Promise.all([
|
||||
const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessRes] = await Promise.all([
|
||||
settingsApi.getPermissionMode(),
|
||||
modelsApi.list(),
|
||||
modelsApi.getCurrent(),
|
||||
modelsApi.getEffort(),
|
||||
settingsApi.getUser(),
|
||||
h5AccessApi.get().catch(() => ({ settings: DEFAULT_H5_ACCESS_SETTINGS })),
|
||||
])
|
||||
const theme = userSettings.theme === 'dark' ? 'dark' : 'light'
|
||||
useUIStore.getState().setTheme(theme)
|
||||
@ -81,6 +103,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
||||
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
|
||||
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||
h5Access: normalizeH5AccessSettings(h5AccessRes.settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
@ -92,6 +116,14 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchH5Access: async () => {
|
||||
const { settings } = await h5AccessApi.get()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
})
|
||||
},
|
||||
|
||||
setPermissionMode: async (mode) => {
|
||||
const prev = get().permissionMode
|
||||
set({ permissionMode: mode })
|
||||
@ -186,6 +218,41 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
set({ webSearch: prev })
|
||||
}
|
||||
},
|
||||
|
||||
enableH5Access: async () => {
|
||||
const { settings, token } = await h5AccessApi.enable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: token,
|
||||
})
|
||||
},
|
||||
|
||||
disableH5Access: async () => {
|
||||
const { settings } = await h5AccessApi.disable()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: null,
|
||||
})
|
||||
},
|
||||
|
||||
regenerateH5AccessToken: async () => {
|
||||
const { settings, token } = await h5AccessApi.regenerate()
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
h5AccessGeneratedToken: token,
|
||||
})
|
||||
},
|
||||
|
||||
updateH5AccessSettings: async (input) => {
|
||||
const { settings } = await h5AccessApi.update(input)
|
||||
set({
|
||||
h5Access: normalizeH5AccessSettings(settings),
|
||||
})
|
||||
},
|
||||
|
||||
clearH5AccessGeneratedToken: () => {
|
||||
set({ h5AccessGeneratedToken: null })
|
||||
},
|
||||
}))
|
||||
|
||||
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {
|
||||
@ -195,3 +262,12 @@ function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): We
|
||||
braveApiKey: settings?.braveApiKey ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings {
|
||||
return {
|
||||
enabled: settings?.enabled === true,
|
||||
tokenPreview: settings?.tokenPreview ?? null,
|
||||
allowedOrigins: Array.isArray(settings?.allowedOrigins) ? settings.allowedOrigins : [],
|
||||
publicBaseUrl: settings?.publicBaseUrl ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,13 @@ export type WebSearchSettings = {
|
||||
braveApiKey?: string
|
||||
}
|
||||
|
||||
export type H5AccessSettings = {
|
||||
enabled: boolean
|
||||
tokenPreview: string | null
|
||||
allowedOrigins: string[]
|
||||
publicBaseUrl: string | null
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user