feat: gate Dreams and add start control (#953)
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 を更新できません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 不可用。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user