feat: gate Dreams and add start control (#953)

This commit is contained in:
Lingxuan Zuo
2026-05-01 15:26:11 +08:00
committed by GitHub
parent cc7992e160
commit d47101ab4c
23 changed files with 1380 additions and 115 deletions

View File

@@ -2,7 +2,7 @@
* Root Application Component
* Handles routing and global providers
*/
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Component, useEffect } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { Toaster } from 'sonner';
@@ -99,6 +99,7 @@ function App() {
const theme = useSettingsStore((state) => state.theme);
const language = useSettingsStore((state) => state.language);
const setupComplete = useSettingsStore((state) => state.setupComplete);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const initGateway = useGatewayStore((state) => state.init);
const initProviders = useProviderStore((state) => state.init);
@@ -192,7 +193,7 @@ function App() {
<Route path="/channels" element={<Channels />} />
<Route path="/skills" element={<Skills />} />
<Route path="/cron" element={<Cron />} />
<Route path="/dreams" element={<Dreams />} />
<Route path="/dreams" element={devModeUnlocked ? <Dreams /> : <Navigate to="/" replace />} />
<Route path="/settings/*" element={<Settings />} />
{extraRoutes.map((r) => (
<Route key={r.path} path={r.path} element={<r.component />} />

View File

@@ -226,7 +226,9 @@ export function Sidebar() {
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
{ to: '/dreams', icon: <Moon className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' },
...(devModeUnlocked
? [{ to: '/dreams', icon: <Moon className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }]
: []),
];
const navItems = [

View File

@@ -17,11 +17,15 @@
},
"actions": {
"title": "Maintenance",
"enable": "Start Dreams",
"disable": "Stop Dreams",
"backfill": "Backfill Diary",
"dedupe": "Dedupe Diary",
"repair": "Repair Cache",
"resetGrounded": "Clear Replayed",
"resetDiary": "Reset Backfilled Diary",
"enableSuccess": "Dreams started. Gateway may restart to apply the managed schedule.",
"disableSuccess": "Dreams stopped. Gateway may restart to remove the managed schedule.",
"backfillSuccess": "Backfilled {{count}} dream diary entries.",
"dedupeSuccess": "Removed {{removed}} duplicate dream entries and kept {{kept}}.",
"repairSuccess": "Dream cache repair completed.",
@@ -62,6 +66,7 @@
"noSnippet": "No snippet available."
},
"errors": {
"openFullUi": "Unable to open the full OpenClaw Dreams UI."
"openFullUi": "Unable to open the full OpenClaw Dreams UI.",
"configHashMissing": "Unable to update Dreams because the config base hash is unavailable."
}
}

View File

@@ -17,11 +17,15 @@
},
"actions": {
"title": "メンテナンス",
"enable": "Dreams を開始",
"disable": "Dreams を停止",
"backfill": "日記をバックフィル",
"dedupe": "日記を重複排除",
"repair": "キャッシュを修復",
"resetGrounded": "再生済みを消去",
"resetDiary": "バックフィル日記をリセット",
"enableSuccess": "Dreams を開始しました。管理スケジュールを適用するため Gateway が再起動する場合があります。",
"disableSuccess": "Dreams を停止しました。管理スケジュールを削除するため Gateway が再起動する場合があります。",
"backfillSuccess": "{{count}} 件の夢日記をバックフィルしました。",
"dedupeSuccess": "{{removed}} 件の重複夢エントリを削除し、{{kept}} 件を保持しました。",
"repairSuccess": "夢キャッシュの修復が完了しました。",
@@ -62,6 +66,7 @@
"noSnippet": "スニペットはありません。"
},
"errors": {
"openFullUi": "完全版 OpenClaw Dreams UI を開けません。"
"openFullUi": "完全版 OpenClaw Dreams UI を開けません。",
"configHashMissing": "config base hash が利用できないため Dreams を更新できません。"
}
}

View File

@@ -17,11 +17,15 @@
},
"actions": {
"title": "Обслуживание",
"enable": "Запустить Dreams",
"disable": "Остановить Dreams",
"backfill": "Backfill Diary",
"dedupe": "Dedupe Diary",
"repair": "Repair Cache",
"resetGrounded": "Очистить replay",
"resetDiary": "Сбросить backfill",
"enableSuccess": "Dreams запущен. Gateway может перезапуститься, чтобы применить управляемое расписание.",
"disableSuccess": "Dreams остановлен. Gateway может перезапуститься, чтобы удалить управляемое расписание.",
"backfillSuccess": "Добавлено {{count}} записей дневника снов.",
"dedupeSuccess": "Удалено {{removed}} дубликатов записей снов, оставлено {{kept}}.",
"repairSuccess": "Восстановление кэша снов завершено.",
@@ -62,6 +66,7 @@
"noSnippet": "Фрагмент недоступен."
},
"errors": {
"openFullUi": "Не удалось открыть полный OpenClaw Dreams UI."
"openFullUi": "Не удалось открыть полный OpenClaw Dreams UI.",
"configHashMissing": "Не удалось обновить Dreams: недоступен config base hash."
}
}

View File

@@ -17,11 +17,15 @@
},
"actions": {
"title": "维护",
"enable": "启动梦境",
"disable": "停止梦境",
"backfill": "回填日记",
"dedupe": "去重日记",
"repair": "修复缓存",
"resetGrounded": "清除已回放",
"resetDiary": "重置回填日记",
"enableSuccess": "梦境已启动。网关可能会重启以应用托管计划。",
"disableSuccess": "梦境已停止。网关可能会重启以移除托管计划。",
"backfillSuccess": "已回填 {{count}} 条梦境日记。",
"dedupeSuccess": "已移除 {{removed}} 条重复梦境,并保留 {{kept}} 条。",
"repairSuccess": "梦境缓存修复完成。",
@@ -62,6 +66,7 @@
"noSnippet": "暂无片段。"
},
"errors": {
"openFullUi": "无法打开完整 OpenClaw Dreams UI。"
"openFullUi": "无法打开完整 OpenClaw Dreams UI。",
"configHashMissing": "无法更新梦境,因为配置 base hash 不可用。"
}
}

View File

@@ -894,9 +894,11 @@ function validateGatewayRpcParams(method: string, params: unknown): void {
if (!params || typeof params !== 'object' || Array.isArray(params)) {
throw new Error('gateway:rpc config.patch requires object params');
}
const raw = (params as Record<string, unknown>).raw;
if (typeof raw === 'string' && raw.trim()) return;
const patch = (params as Record<string, unknown>).patch;
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new Error('gateway:rpc config.patch requires object patch');
throw new Error('gateway:rpc config.patch requires raw string or object patch');
}
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Archive,
BookOpen,
@@ -7,6 +7,7 @@ import {
ExternalLink,
Loader2,
Moon,
Power,
RefreshCw,
RotateCcw,
Sparkles,
@@ -84,7 +85,16 @@ interface DreamDiaryEntry {
summary: string;
}
interface ConfigSnapshot {
hash?: string;
}
type DreamActionKey = 'backfill' | 'dedupe' | 'repair' | 'resetDiary' | 'resetGrounded';
type DreamToggleKey = 'enable' | 'disable';
interface RefreshOptions {
force?: boolean;
}
interface PendingConfirmation {
action: DreamActionKey;
@@ -103,6 +113,22 @@ const DREAM_ACTION_METHODS: Record<DreamActionKey, string> = {
const DIARY_START_MARKER = '<!-- openclaw:dreaming:diary:start -->';
const DIARY_END_MARKER = '<!-- openclaw:dreaming:diary:end -->';
function buildDreamingEnabledPatchRaw(enabled: boolean): string {
return JSON.stringify({
plugins: {
entries: {
'memory-core': {
config: {
dreaming: {
enabled,
},
},
},
},
},
});
}
const PANEL_CLASS = 'border-black/10 bg-surface-modal shadow-sm dark:border-white/10';
const INSET_CLASS = 'border-black/10 bg-surface-input dark:border-white/10';
const QUIET_BUTTON_CLASS = 'border-black/10 bg-surface-input text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:border-white/10 dark:hover:bg-white/5';
@@ -190,12 +216,15 @@ export function Dreams() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [runningAction, setRunningAction] = useState<DreamActionKey | null>(null);
const [runningToggle, setRunningToggle] = useState<DreamToggleKey | null>(null);
const [lastActionMessage, setLastActionMessage] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<PendingConfirmation | null>(null);
const [openingFullUi, setOpeningFullUi] = useState(false);
const refreshInFlightRef = useRef<Promise<void> | null>(null);
const gatewayReady = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
const actionsDisabled = !gatewayReady || runningAction != null;
const gatewayRunning = gatewayStatus.state === 'running';
const busy = runningAction != null || runningToggle != null;
const actionsDisabled = !gatewayRunning || busy;
const diaryEntries = useMemo(() => parseDreamDiary(diary?.content).slice(0, 4), [diary?.content]);
const recentSignals = useMemo(() => {
@@ -204,29 +233,42 @@ export function Dreams() {
return [...shortTerm, ...promoted].slice(0, 6);
}, [dreaming?.promotedEntries, dreaming?.shortTermEntries]);
const refreshAll = useCallback(async () => {
if (!gatewayReady) {
const refreshAll = useCallback(async (options?: RefreshOptions) => {
if (refreshInFlightRef.current && !options?.force) {
return refreshInFlightRef.current;
}
if (!gatewayRunning) {
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const [statusResponse, diaryResponse] = await Promise.all([
rpc<unknown>('doctor.memory.status', {}, 12_000),
rpc<DreamDiaryResponse>('doctor.memory.dreamDiary', {}, 12_000),
]);
setDreaming(normalizeDreamingStatus(statusResponse));
setDiary(diaryResponse);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setLoading(false);
}
}, [gatewayReady, rpc]);
let refreshPromise!: Promise<void>;
refreshPromise = (async () => {
setLoading(true);
setError(null);
try {
const [statusResponse, diaryResponse] = await Promise.all([
rpc<unknown>('doctor.memory.status', {}, 12_000),
rpc<DreamDiaryResponse>('doctor.memory.dreamDiary', {}, 12_000),
]);
setDreaming(normalizeDreamingStatus(statusResponse));
setDiary(diaryResponse);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setLoading(false);
if (refreshInFlightRef.current === refreshPromise) {
refreshInFlightRef.current = null;
}
}
})();
refreshInFlightRef.current = refreshPromise;
return refreshPromise;
}, [gatewayRunning, rpc]);
useEffect(() => {
void refreshAll();
@@ -273,6 +315,34 @@ export function Dreams() {
}
}, [buildActionMessage, refreshAll, rpc]);
const setDreamingEnabled = useCallback(async (enabled: boolean) => {
const toggleKey: DreamToggleKey = enabled ? 'enable' : 'disable';
setRunningToggle(toggleKey);
setError(null);
setLastActionMessage(null);
try {
const snapshot = await rpc<ConfigSnapshot>('config.get', {}, 12_000);
if (!snapshot.hash) {
throw new Error(t('errors.configHashMissing'));
}
await rpc<unknown>('config.patch', {
raw: buildDreamingEnabledPatchRaw(enabled),
baseHash: snapshot.hash,
note: enabled ? 'Enable memory dreaming from ClawX Dreams.' : 'Disable memory dreaming from ClawX Dreams.',
}, 30_000);
const message = enabled ? t('actions.enableSuccess') : t('actions.disableSuccess');
setDreaming((current) => ({ ...(current ?? {}), enabled }));
setLastActionMessage(message);
toast.success(message);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
toast.error(message);
} finally {
setRunningToggle(null);
}
}, [rpc, t]);
const requestConfirmation = useCallback((action: DreamActionKey) => {
setPendingConfirmation({
action,
@@ -332,12 +402,23 @@ export function Dreams() {
<p className="mt-2 text-subtitle font-medium text-foreground/60">{t('subtitle')}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
data-testid={dreaming?.enabled ? 'dreams-disable' : 'dreams-enable'}
variant={dreaming?.enabled ? 'outline' : 'default'}
size="sm"
onClick={() => void setDreamingEnabled(!dreaming?.enabled)}
disabled={!gatewayRunning || busy || loading}
className={dreaming?.enabled ? QUIET_BUTTON_CLASS : undefined}
>
{runningToggle ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Power className="mr-2 h-4 w-4" />}
{dreaming?.enabled ? t('actions.disable') : t('actions.enable')}
</Button>
<Button
data-testid="dreams-refresh"
variant="outline"
size="sm"
onClick={() => void refreshAll()}
disabled={loading || !gatewayReady}
onClick={() => void refreshAll({ force: true })}
disabled={!gatewayRunning}
className={QUIET_BUTTON_CLASS}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
@@ -348,7 +429,7 @@ export function Dreams() {
variant="secondary"
size="sm"
onClick={() => void openFullDreams()}
disabled={openingFullUi || !gatewayReady}
disabled={openingFullUi || !gatewayRunning}
className="border border-black/10 bg-card text-foreground shadow-sm hover:bg-black/5 dark:border-white/10 dark:bg-card dark:hover:bg-white/5"
>
{openingFullUi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ExternalLink className="mr-2 h-4 w-4" />}
@@ -358,7 +439,7 @@ export function Dreams() {
</header>
<main className="min-h-0 flex-1 overflow-auto px-10 pb-10">
{!gatewayReady && (
{!gatewayRunning && (
<div className="mb-4 rounded-lg border border-black/10 bg-surface-input px-4 py-3 text-sm text-foreground/70 dark:border-white/10">
{t('gatewayNotReady')}
</div>