feat(desktop): add Auto-dream setting (#800)

Expose the existing CLI Auto-dream switch in General settings with an explicit opt-in confirmation before enabling background memory consolidation.

Tested: bun run check:desktop
Tested: ./bin/claude-haha --help
Tested: CLAUDE_CONFIG_DIR temp settings true/false autoDreamEnabled check
Tested: real provider Auto-dream trigger against temporary memory copy
Scope-risk: narrow
Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:51:05 +08:00
parent 00b4ead0ca
commit 52aa3c5c02
10 changed files with 265 additions and 0 deletions

View File

@ -204,6 +204,7 @@ describe('Settings > General tab', () => {
locale: 'en',
theme: 'light',
thinkingEnabled: true,
autoDreamEnabled: false,
skipWebFetchPreflight: true,
desktopNotificationsEnabled: true,
chatSendBehavior: 'enter',
@ -242,6 +243,9 @@ describe('Settings > General tab', () => {
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
}),
setAutoDreamEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ autoDreamEnabled: enabled })
}),
setTheme: vi.fn().mockImplementation(async (theme: ThemeMode) => {
useSettingsStore.setState({ theme })
}),
@ -718,6 +722,41 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setThinkingEnabled).toHaveBeenCalledWith(false)
})
it('keeps Auto-dream disabled by default and confirms before enabling it', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByLabelText('Enable Auto-dream')
expect(toggle).not.toBeChecked()
fireEvent.click(toggle)
expect(useSettingsStore.getState().setAutoDreamEnabled).not.toHaveBeenCalled()
const dialog = screen.getByRole('dialog', { name: 'Enable Auto-dream?' })
expect(within(dialog).getByText(/Keep the desktop app running/i)).toBeInTheDocument()
expect(within(dialog).getByText(/uses additional model tokens/i)).toBeInTheDocument()
await act(async () => {
fireEvent.click(within(dialog).getByRole('button', { name: 'Enable Auto-dream' }))
})
expect(useSettingsStore.getState().setAutoDreamEnabled).toHaveBeenCalledWith(true)
expect(screen.getByLabelText('Enable Auto-dream')).toBeChecked()
})
it('lets the user disable Auto-dream without a confirmation dialog', async () => {
useSettingsStore.setState({ autoDreamEnabled: true })
render(<Settings />)
fireEvent.click(screen.getByText('General'))
await act(async () => {
fireEvent.click(screen.getByLabelText('Enable Auto-dream'))
})
expect(screen.queryByRole('dialog', { name: 'Enable Auto-dream?' })).not.toBeInTheDocument()
expect(useSettingsStore.getState().setAutoDreamEnabled).toHaveBeenCalledWith(false)
})
it('uses the shared dropdown for response language', () => {
render(<Settings />)

View File

@ -970,6 +970,15 @@ export const en = {
'settings.general.thinkingDescription': 'Controls whether new sessions start with model thinking enabled. When off, compatible providers such as DeepSeek receive an explicit non-thinking parameter.',
'settings.general.thinkingEnabled': 'Enable thinking mode',
'settings.general.thinkingHint': 'Turn this off to start new sessions with --thinking disabled; useful for DeepSeek V4 Flash/Pro and other non-thinking workflows.',
'settings.general.autoDreamTitle': 'Auto-dream',
'settings.general.autoDreamDescription': 'Periodically consolidates auto-memory in the background after enough sessions have changed.',
'settings.general.autoDreamEnabled': 'Enable Auto-dream',
'settings.general.autoDreamHintOn': 'The desktop app can run memory consolidation after eligible sessions finish.',
'settings.general.autoDreamHintOff': 'Off by default because it can run background model calls.',
'settings.general.autoDreamConfirmTitle': 'Enable Auto-dream?',
'settings.general.autoDreamConfirmKeepRunning': 'Keep the desktop app running so background memory consolidation can start after eligible sessions finish.',
'settings.general.autoDreamConfirmTokenCost': 'Auto-dream uses additional model tokens when it reviews and rewrites memory files.',
'settings.general.autoDreamConfirmEnable': 'Enable Auto-dream',
'settings.general.notificationsTitle': 'System Notifications',
'settings.general.notificationsDescription': 'Use native OS notifications for permission prompts, agent replies, and scheduled task results.',
'settings.general.notificationsEnabled': 'Enable system notifications',

View File

@ -972,6 +972,15 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.thinkingDescription': '新しいセッションをモデルの思考を有効にして開始するかどうかを制御します。オフの場合、DeepSeek などの対応プロバイダーには明示的に非思考パラメーターが渡されます。',
'settings.general.thinkingEnabled': '思考モードを有効にする',
'settings.general.thinkingHint': '新しいセッションを --thinking 無効で開始するにはこれをオフにします。DeepSeek V4 Flash/Pro やその他の非思考ワークフローに便利です。',
'settings.general.autoDreamTitle': 'Auto-dream',
'settings.general.autoDreamDescription': '十分なセッションが変更された後、バックグラウンドで auto-memory を定期的に統合します。',
'settings.general.autoDreamEnabled': 'Auto-dream を有効にする',
'settings.general.autoDreamHintOn': '対象セッションの終了後、デスクトップアプリがバックグラウンドで記憶を整理できます。',
'settings.general.autoDreamHintOff': 'バックグラウンドのモデル呼び出しが発生する可能性があるため、既定ではオフです。',
'settings.general.autoDreamConfirmTitle': 'Auto-dream を有効にしますか?',
'settings.general.autoDreamConfirmKeepRunning': '対象セッションの終了後にバックグラウンドの記憶整理を開始できるよう、デスクトップアプリを起動したままにしてください。',
'settings.general.autoDreamConfirmTokenCost': 'Auto-dream は記憶ファイルの確認と書き換え時に追加のモデル Token を使用します。',
'settings.general.autoDreamConfirmEnable': 'Auto-dream を有効にする',
'settings.general.notificationsTitle': 'システム通知',
'settings.general.notificationsDescription': '権限の確認、エージェントの応答、スケジュールタスクの結果に OS ネイティブの通知を使用します。',
'settings.general.notificationsEnabled': 'システム通知を有効にする',

View File

@ -972,6 +972,15 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.thinkingDescription': '새 세션을 모델 사고를 사용으로 시작할지 제어합니다. 꺼져 있으면 DeepSeek 같은 호환 공급자에 명시적인 비사고 매개변수가 전달됩니다.',
'settings.general.thinkingEnabled': '사고 모드 사용',
'settings.general.thinkingHint': '새 세션을 --thinking 비활성화로 시작하려면 이를 끄세요. DeepSeek V4 Flash/Pro 및 기타 비사고 워크플로에 유용합니다.',
'settings.general.autoDreamTitle': 'Auto-dream',
'settings.general.autoDreamDescription': '충분한 세션이 변경된 후 백그라운드에서 auto-memory를 주기적으로 통합합니다.',
'settings.general.autoDreamEnabled': 'Auto-dream 사용',
'settings.general.autoDreamHintOn': '대상 세션이 끝나면 데스크톱 앱이 백그라운드에서 메모리를 정리할 수 있습니다.',
'settings.general.autoDreamHintOff': '백그라운드 모델 호출을 실행할 수 있으므로 기본값은 꺼짐입니다.',
'settings.general.autoDreamConfirmTitle': 'Auto-dream을 사용하시겠습니까?',
'settings.general.autoDreamConfirmKeepRunning': '대상 세션이 끝난 후 백그라운드 메모리 정리를 시작할 수 있도록 데스크톱 앱을 계속 실행해 두세요.',
'settings.general.autoDreamConfirmTokenCost': 'Auto-dream은 메모리 파일을 검토하고 다시 쓸 때 추가 모델 Token을 사용합니다.',
'settings.general.autoDreamConfirmEnable': 'Auto-dream 사용',
'settings.general.notificationsTitle': '시스템 알림',
'settings.general.notificationsDescription': '권한 확인, 에이전트 응답, 예약 작업 결과에 OS 기본 알림을 사용합니다.',
'settings.general.notificationsEnabled': '시스템 알림 사용',

View File

@ -972,6 +972,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.thinkingDescription': '控制新會話是否啟用模型思考。關閉後DeepSeek 等相容供應商會收到顯式非思考模式引數。',
'settings.general.thinkingEnabled': '啟用思考模式',
'settings.general.thinkingHint': '關閉後會以 --thinking disabled 啟動新會話;適合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。',
'settings.general.autoDreamTitle': '自動做夢',
'settings.general.autoDreamDescription': '在累積足夠會話後,背景定期整理和壓縮 auto-memory。',
'settings.general.autoDreamEnabled': '啟用自動做夢',
'settings.general.autoDreamHintOn': '符合條件的會話結束後,桌面端可以在背景執行記憶整理。',
'settings.general.autoDreamHintOff': '預設關閉,因為它可能發起背景模型呼叫。',
'settings.general.autoDreamConfirmTitle': '啟用自動做夢?',
'settings.general.autoDreamConfirmKeepRunning': '請保持桌面端處於啟用狀態,這樣符合條件的會話結束後才能啟動背景記憶整理。',
'settings.general.autoDreamConfirmTokenCost': '自動做夢會在審查和改寫記憶檔案時額外消耗模型 Token。',
'settings.general.autoDreamConfirmEnable': '啟用自動做夢',
'settings.general.notificationsTitle': '系統通知',
'settings.general.notificationsDescription': '使用作業系統原生通知提醒授權確認、Agent 回覆完成和定時任務結果。',
'settings.general.notificationsEnabled': '啟用系統通知',

View File

@ -972,6 +972,15 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.thinkingDescription': '控制新会话是否启用模型思考。关闭后DeepSeek 等兼容供应商会收到显式非思考模式参数。',
'settings.general.thinkingEnabled': '启用思考模式',
'settings.general.thinkingHint': '关闭后会以 --thinking disabled 启动新会话;适合 DeepSeek V4 Flash/Pro 等需要非思考模式的模型。',
'settings.general.autoDreamTitle': '自动做梦',
'settings.general.autoDreamDescription': '在积累足够会话后,后台定期整理和压缩 auto-memory。',
'settings.general.autoDreamEnabled': '启用自动做梦',
'settings.general.autoDreamHintOn': '符合条件的会话结束后,桌面端可以在后台执行记忆整理。',
'settings.general.autoDreamHintOff': '默认关闭,因为它可能发起后台模型调用。',
'settings.general.autoDreamConfirmTitle': '启用自动做梦?',
'settings.general.autoDreamConfirmKeepRunning': '请保持桌面端处于启用状态,这样符合条件的会话结束后才能启动后台记忆整理。',
'settings.general.autoDreamConfirmTokenCost': '自动做梦会在审查和改写记忆文件时额外消耗模型 Token。',
'settings.general.autoDreamConfirmEnable': '启用自动做梦',
'settings.general.notificationsTitle': '系统通知',
'settings.general.notificationsDescription': '使用操作系统原生通知提醒授权确认、Agent 回复完成和定时任务结果。',
'settings.general.notificationsEnabled': '启用系统通知',

View File

@ -1438,6 +1438,8 @@ export function GeneralSettings() {
const {
thinkingEnabled,
setThinkingEnabled,
autoDreamEnabled,
setAutoDreamEnabled,
locale,
setLocale,
theme,
@ -1480,6 +1482,8 @@ export function GeneralSettings() {
const [isSavingNetwork, setIsSavingNetwork] = useState(false)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [autoDreamConfirmOpen, setAutoDreamConfirmOpen] = useState(false)
const [autoDreamActionRunning, setAutoDreamActionRunning] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
const [pendingPortableDir, setPendingPortableDir] = useState<string | null>(null)
@ -1662,6 +1666,24 @@ export function GeneralSettings() {
}
}
const handleAutoDreamToggle = (enabled: boolean) => {
if (enabled) {
setAutoDreamConfirmOpen(true)
return
}
void setAutoDreamEnabled(false)
}
const confirmAutoDreamEnable = async () => {
setAutoDreamActionRunning(true)
try {
await setAutoDreamEnabled(true)
setAutoDreamConfirmOpen(false)
} finally {
setAutoDreamActionRunning(false)
}
}
const handleNotificationPermissionAction = async () => {
setNotificationActionRunning(true)
try {
@ -2094,6 +2116,31 @@ export function GeneralSettings() {
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.autoDreamTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.autoDreamDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.autoDreamEnabled')}
checked={autoDreamEnabled}
onChange={(e) => handleAutoDreamToggle(e.target.checked)}
className="peer sr-only"
/>
<SettingsCheckboxMark checked={autoDreamEnabled} />
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.autoDreamEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{autoDreamEnabled
? t('settings.general.autoDreamHintOn')
: t('settings.general.autoDreamHintOff')}
</div>
</div>
</label>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.traceTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.traceDescription')}</p>
@ -2615,6 +2662,24 @@ export function GeneralSettings() {
confirmVariant="primary"
loading={modeActionRunning}
/>
<ConfirmDialog
open={autoDreamConfirmOpen}
onClose={() => {
if (!autoDreamActionRunning) setAutoDreamConfirmOpen(false)
}}
onConfirm={() => void confirmAutoDreamEnable()}
title={t('settings.general.autoDreamConfirmTitle')}
body={(
<div className="space-y-2">
<p>{t('settings.general.autoDreamConfirmKeepRunning')}</p>
<p>{t('settings.general.autoDreamConfirmTokenCost')}</p>
</div>
)}
confirmLabel={t('settings.general.autoDreamConfirmEnable')}
cancelLabel={t('common.cancel')}
confirmVariant="primary"
loading={autoDreamActionRunning}
/>
</div>
)
}

View File

@ -753,6 +753,106 @@ describe('settingsStore thinking persistence', () => {
})
})
describe('settingsStore Auto-dream persistence', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
window.localStorage.clear()
})
it('keeps Auto-dream off unless user settings opt in', async () => {
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn().mockResolvedValue({}),
updateUser: vi.fn(),
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn().mockResolvedValue({ models: [] }),
getCurrent: vi.fn().mockResolvedValue({ model: null }),
setCurrent: vi.fn(),
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn().mockResolvedValue({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
},
}),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
expect(useSettingsStore.getState().autoDreamEnabled).toBe(false)
await useSettingsStore.getState().fetchAll()
expect(useSettingsStore.getState().autoDreamEnabled).toBe(false)
})
it('hydrates and persists Auto-dream explicitly', async () => {
const updateUser = vi.fn().mockResolvedValue({})
vi.doMock('../api/settings', () => ({
settingsApi: {
getUser: vi.fn().mockResolvedValue({ autoDreamEnabled: true }),
updateUser,
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
setPermissionMode: vi.fn(),
getCliLauncherStatus: vi.fn(),
},
}))
vi.doMock('../api/models', () => ({
modelsApi: {
list: vi.fn().mockResolvedValue({ models: [] }),
getCurrent: vi.fn().mockResolvedValue({ model: null }),
setCurrent: vi.fn(),
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
setEffort: vi.fn(),
},
}))
vi.doMock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn().mockResolvedValue({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
},
}),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAll()
expect(useSettingsStore.getState().autoDreamEnabled).toBe(true)
await useSettingsStore.getState().setAutoDreamEnabled(false)
expect(updateUser).toHaveBeenCalledWith({ autoDreamEnabled: false })
expect(useSettingsStore.getState().autoDreamEnabled).toBe(false)
})
})
describe('settingsStore desktop terminal shell persistence', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -60,6 +60,7 @@ type SettingsStore = {
currentModel: ModelInfo | null
effortLevel: EffortLevel
thinkingEnabled: boolean
autoDreamEnabled: boolean
availableModels: ModelInfo[]
activeProviderName: string | null
locale: Locale
@ -95,6 +96,7 @@ type SettingsStore = {
setModel: (modelId: string) => Promise<void>
setEffort: (level: EffortLevel) => Promise<void>
setThinkingEnabled: (enabled: boolean) => Promise<void>
setAutoDreamEnabled: (enabled: boolean) => Promise<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setChatSendBehavior: (behavior: ChatSendBehavior) => Promise<void>
@ -169,6 +171,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: null,
effortLevel: 'max',
thinkingEnabled: true,
autoDreamEnabled: false,
availableModels: [],
activeProviderName: null,
locale: getStoredLocale(),
@ -231,6 +234,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: model,
effortLevel: level,
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
autoDreamEnabled: userSettings.autoDreamEnabled === true,
theme,
chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior),
outputStyle: normalizeOutputStyle(userSettings.outputStyle),
@ -301,6 +305,17 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
setAutoDreamEnabled: async (enabled) => {
const prev = get().autoDreamEnabled
set({ autoDreamEnabled: enabled })
try {
await settingsApi.updateUser({ autoDreamEnabled: enabled })
} catch (error) {
set({ autoDreamEnabled: prev })
throw error
}
},
setLocale: (locale) => {
set({ locale })
try { localStorage.setItem(LOCALE_STORAGE_KEY, locale) } catch { /* noop */ }

View File

@ -102,6 +102,7 @@ export type UserSettings = {
modelContext?: string
effort?: EffortLevel
alwaysThinkingEnabled?: boolean
autoDreamEnabled?: boolean
permissionMode?: PermissionMode
theme?: ThemeMode
chatSendBehavior?: ChatSendBehavior