import { showConfirm } from '../components/modal.js' import { toast } from '../components/toast.js' import { t } from '../lib/i18n.js' import { icon } from '../lib/icons.js' import { wsClient } from '../lib/ws-client.js' import { navigate } from '../router.js' let _page = null let _unsubReady = null let _state = createState() function createState() { return { loading: true, actionLoading: false, view: 'scene', unsupported: false, error: '', status: null, configSnapshot: null, pluginId: 'memory-core', pluginSupportsDreaming: null, toggleBlockedReason: '', diaryPath: 'DREAMS.md', diaryContent: null, diarySupported: true, actionsSupported: true, } } function asRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null } function normalizeString(value, fallback = '') { return typeof value === 'string' ? value : fallback } function normalizeInt(value, fallback = 0) { return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback } function normalizeEntries(raw) { if (!Array.isArray(raw)) return [] return raw.map((entry) => { const record = asRecord(entry) if (!record) return null const snippet = normalizeString(record.snippet) const path = normalizeString(record.path) const key = normalizeString(record.key || path || snippet) if (!snippet && !path) return null return { key: key || `${path}:${normalizeInt(record.startLine, 1)}`, snippet, path, startLine: normalizeInt(record.startLine, 1), endLine: normalizeInt(record.endLine, 1), recallCount: normalizeInt(record.recallCount, 0), dailyCount: normalizeInt(record.dailyCount, 0), groundedCount: normalizeInt(record.groundedCount, 0), totalSignalCount: normalizeInt(record.totalSignalCount, 0), phaseHitCount: normalizeInt(record.phaseHitCount, 0), promotedAt: normalizeString(record.promotedAt || ''), } }).filter(Boolean) } function normalizePhase(raw) { const record = asRecord(raw) return { enabled: record?.enabled === true, cron: normalizeString(record?.cron), nextRunAtMs: typeof record?.nextRunAtMs === 'number' && Number.isFinite(record.nextRunAtMs) ? record.nextRunAtMs : null, limit: normalizeInt(record?.limit, 0), lookbackDays: normalizeInt(record?.lookbackDays, 0), minScore: typeof record?.minScore === 'number' && Number.isFinite(record.minScore) ? record.minScore : null, minPatternStrength: typeof record?.minPatternStrength === 'number' && Number.isFinite(record.minPatternStrength) ? record.minPatternStrength : null, minRecallCount: normalizeInt(record?.minRecallCount, 0), minUniqueQueries: normalizeInt(record?.minUniqueQueries, 0), } } function normalizeStatus(raw) { const record = asRecord(raw) if (!record) return null const phases = asRecord(record.phases) return { enabled: record.enabled === true, timezone: normalizeString(record.timezone || ''), storageMode: normalizeString(record.storageMode || 'inline'), shortTermCount: normalizeInt(record.shortTermCount, 0), groundedSignalCount: normalizeInt(record.groundedSignalCount, 0), totalSignalCount: normalizeInt(record.totalSignalCount, 0), promotedToday: normalizeInt(record.promotedToday, 0), promotedTotal: normalizeInt(record.promotedTotal, 0), storePath: normalizeString(record.storePath || 'MEMORY.md'), shortTermEntries: normalizeEntries(record.shortTermEntries), signalEntries: normalizeEntries(record.signalEntries), promotedEntries: normalizeEntries(record.promotedEntries), phases: { light: normalizePhase(phases?.light), deep: normalizePhase(phases?.deep), rem: normalizePhase(phases?.rem), }, } } function isUnsupportedError(error) { const msg = String(error?.message || error || '').toLowerCase() return msg.includes('unknown method') || msg.includes('not found') || msg.includes('unsupported') || msg.includes('不支持') } function errorMessage(error) { return String(error?.message || error || '') } function lookupIncludesDreamingProperty(value) { const lookup = asRecord(value) const children = Array.isArray(lookup?.children) ? lookup.children : [] return children.some((child) => normalizeString(asRecord(child)?.key) === 'dreaming') } function lookupDisallowsUnknownProperties(value) { const lookup = asRecord(value) const schema = asRecord(lookup?.schema) return schema?.additionalProperties === false } function parseDiarySections(content) { if (typeof content !== 'string') return [] const normalized = content.replace(/\r\n/g, '\n').trim() if (!normalized) return [] const matches = Array.from(normalized.matchAll(/^(#{1,6})\s+(.+)$/gm)) if (!matches.length) { return [{ title: `${t('dreaming.diarySection')} 1`, body: normalized }] } const result = [] for (let i = 0; i < matches.length; i++) { const current = matches[i] const start = (current.index ?? 0) + current[0].length const end = i + 1 < matches.length ? (matches[i + 1].index ?? normalized.length) : normalized.length const title = normalizeString(current[2], `${t('dreaming.diarySection')} ${i + 1}`).trim() || `${t('dreaming.diarySection')} ${i + 1}` const body = normalized.slice(start, end).trim() result.push({ title, body: body || current[0] }) } return result.filter((section) => section.title || section.body) } function esc(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function formatNextRun(ms) { if (typeof ms !== 'number' || !Number.isFinite(ms)) return t('dreaming.notScheduled') return new Date(ms).toLocaleString() } function resolveNextRun(status) { if (!status?.phases) return null const values = Object.values(status.phases) .filter((phase) => phase.enabled && typeof phase.nextRunAtMs === 'number') .map((phase) => phase.nextRunAtMs) .sort((a, b) => a - b) return values[0] ?? null } function resolveMemoryPluginId(config) { const root = asRecord(config) const plugins = asRecord(root?.plugins) const slots = asRecord(plugins?.slots) const slot = normalizeString(slots?.memory || '').trim() if (slot && slot.toLowerCase() !== 'none') return slot return 'memory-core' } async function ensureGatewayReady(page) { if (wsClient.connected && wsClient.gatewayReady) return true if (_unsubReady) { _unsubReady(); _unsubReady = null } _unsubReady = wsClient.onReady(() => { if (_unsubReady) { _unsubReady(); _unsubReady = null } if (_page === page) loadAll(page) }) return false } export function render() { const page = document.createElement('div') page.className = 'page' _page = page _state = createState() renderPage(page) setTimeout(() => loadAll(page), 0) return page } export function cleanup() { _page = null if (_unsubReady) { _unsubReady(); _unsubReady = null } } async function loadAll(page) { if (_page !== page) return if (!(await ensureGatewayReady(page))) { _state.loading = false _state.actionLoading = false renderPage(page) return } _state.loading = true _state.error = '' _state.unsupported = false _state.toggleBlockedReason = '' _state.pluginSupportsDreaming = null renderPage(page) const [statusResult, diaryResult, configResult] = await Promise.allSettled([ wsClient.request('doctor.memory.status', {}), wsClient.request('doctor.memory.dreamDiary', {}), wsClient.request('config.get', {}), ]) if (_page !== page) return if (statusResult.status === 'fulfilled') { _state.status = normalizeStatus(statusResult.value?.dreaming ?? statusResult.value) _state.actionsSupported = true } else { _state.status = null _state.error = errorMessage(statusResult.reason) _state.unsupported = isUnsupportedError(statusResult.reason) _state.actionsSupported = !_state.unsupported } if (diaryResult.status === 'fulfilled') { const payload = diaryResult.value || {} _state.diaryPath = normalizeString(payload.path || 'DREAMS.md') _state.diaryContent = payload.found === false ? null : (typeof payload.content === 'string' ? payload.content : null) _state.diarySupported = true } else { _state.diarySupported = !isUnsupportedError(diaryResult.reason) if (!_state.diarySupported) { _state.diaryContent = null } else if (!_state.error) { _state.error = errorMessage(diaryResult.reason) } } if (configResult.status === 'fulfilled') { const snapshot = asRecord(configResult.value) _state.configSnapshot = snapshot && typeof snapshot.hash === 'string' ? snapshot : null _state.pluginId = resolveMemoryPluginId(_state.configSnapshot?.config) if (!_state.configSnapshot?.hash) { _state.toggleBlockedReason = t('dreaming.configUnavailable') } else { try { const lookup = await wsClient.request('config.schema.lookup', { path: `plugins.entries.${_state.pluginId}.config`, }) const hasDreaming = lookupIncludesDreamingProperty(lookup) const strictSchema = lookupDisallowsUnknownProperties(lookup) if (hasDreaming) { _state.pluginSupportsDreaming = true } else if (strictSchema) { _state.pluginSupportsDreaming = false _state.toggleBlockedReason = t('dreaming.pluginUnsupported') } } catch (lookupError) { if (!isUnsupportedError(lookupError) && !_state.toggleBlockedReason) { _state.toggleBlockedReason = '' } } } } else { _state.configSnapshot = null _state.toggleBlockedReason = t('dreaming.configUnavailable') } _state.loading = false _state.actionLoading = false renderPage(page) } async function runAction(method, successText, options = {}) { if (!_page || _state.actionLoading) return if (!(wsClient.connected && wsClient.gatewayReady)) { toast(t('dreaming.gwWait'), 'warning') return } _state.actionLoading = true renderPage(_page) try { await wsClient.request(method, {}) toast(successText, 'success') await loadAll(_page) } catch (e) { if (isUnsupportedError(e)) { toast(t('dreaming.rpcUnsupported'), 'warning') } else { toast(`${t('dreaming.loadFailed')}: ${e?.message || e}`, 'error') } _state.actionLoading = false renderPage(_page) } } async function toggleDreaming() { if (!_page || _state.actionLoading) return if (!(wsClient.connected && wsClient.gatewayReady)) { toast(t('dreaming.gwWait'), 'warning') return } if (_state.toggleBlockedReason) { toast(_state.toggleBlockedReason, 'warning') return } if (!_state.configSnapshot?.hash) { toast(t('dreaming.configUnavailable'), 'warning') return } if (_state.pluginSupportsDreaming === false) { toast(t('dreaming.pluginUnsupported'), 'warning') return } const enabled = _state.status?.enabled === true const pluginId = resolveMemoryPluginId(_state.configSnapshot.config) _state.actionLoading = true renderPage(_page) try { await wsClient.request('config.patch', { baseHash: _state.configSnapshot.hash, raw: JSON.stringify({ plugins: { entries: { [pluginId]: { config: { dreaming: { enabled: !enabled, }, }, }, }, }, }), sessionKey: wsClient.sessionKey || undefined, note: 'Dreaming settings updated from ClawPanel.', }) toast(!enabled ? t('dreaming.enabled') : t('dreaming.disabled'), 'success') await loadAll(_page) } catch (e) { if (isUnsupportedError(e)) { if (!_state.toggleBlockedReason) _state.toggleBlockedReason = t('dreaming.pluginUnsupported') toast(t('dreaming.rpcUnsupported'), 'warning') } else { toast(`${t('dreaming.toggleFailed')}: ${errorMessage(e)}`, 'error') } _state.actionLoading = false renderPage(_page) } } function renderStatCard(label, value, meta = '') { return `
${esc(label)}
${esc(value)}
${meta ? `
${esc(meta)}
` : ''}
` } function renderPhaseCard(title, phase) { const meta = [ phase.cron ? `${t('dreaming.cron')}: ${phase.cron}` : t('dreaming.notScheduled'), phase.nextRunAtMs ? `${t('dreaming.nextRun')}: ${formatNextRun(phase.nextRunAtMs)}` : '', ].filter(Boolean).join(' · ') const details = [ phase.limit ? `limit ${phase.limit}` : '', phase.lookbackDays ? `lookback ${phase.lookbackDays}d` : '', typeof phase.minScore === 'number' ? `score≥${phase.minScore}` : '', typeof phase.minPatternStrength === 'number' ? `pattern≥${phase.minPatternStrength}` : '', phase.minRecallCount ? `recalls≥${phase.minRecallCount}` : '', phase.minUniqueQueries ? `uniq≥${phase.minUniqueQueries}` : '', ].filter(Boolean).join(' · ') return `
${esc(title)} ${esc(phase.enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))}
${esc(meta || t('dreaming.notScheduled'))}
${details ? `
${esc(details)}
` : ''}
` } function renderEntries(title, entries) { const content = entries.length ? entries.slice(0, 8).map((entry) => `
${esc(entry.snippet || '(empty)')}
${esc(entry.path)}${entry.startLine ? ':' + entry.startLine : ''}${entry.endLine && entry.endLine !== entry.startLine ? '-' + entry.endLine : ''}
${esc([ entry.recallCount ? `${entry.recallCount} recall` : '', entry.dailyCount ? `${entry.dailyCount} daily` : '', entry.groundedCount ? `${entry.groundedCount} grounded` : '', entry.totalSignalCount ? `${entry.totalSignalCount} signals` : '', entry.phaseHitCount ? `${entry.phaseHitCount} ${t('dreaming.phaseHits')}` : '', ].filter(Boolean).join(' · '))}
`).join('') : `
${esc(t('dreaming.noEntries'))}
` return `
${esc(title)}
${content}
` } function renderActionButtons(enabled, disabledAttr) { const toggleText = enabled ? t('dreaming.toggleOff') : t('dreaming.toggleOn') const actionsDisabled = !_state.actionsSupported ? 'disabled title="' + esc(t('dreaming.rpcUnsupported')) + '"' : disabledAttr const diaryDisabled = !_state.diarySupported ? 'disabled title="' + esc(t('dreaming.rpcUnsupported')) + '"' : disabledAttr return `
` } function renderStatusHints() { const hints = [] if (_state.toggleBlockedReason) hints.push(`
${esc(_state.toggleBlockedReason)}
`) if (!_state.diarySupported || !_state.actionsSupported) hints.push(`
${esc(t('dreaming.rpcUnsupported'))}
`) if (_state.error && !_state.unsupported) hints.push(`
${esc(_state.error)}
`) return hints.join('') } function renderViewTabs() { return `
${esc(t('dreaming.viewScene'))}
${esc(t('dreaming.viewDiary'))}
` } function renderDreamLane(title, subtitle, entries, accent) { const tones = { violet: { dot: '#a855f7', border: 'var(--accent, #6366f1)' }, cyan: { dot: '#22d3ee', border: 'var(--success, #22c55e)' }, amber: { dot: '#fbbf24', border: 'var(--warning, #f59e0b)' }, } const tone = tones[accent] || tones.violet const items = entries.length ? entries.slice(0, 4).map((entry, idx) => `
${esc(entry.snippet || '(empty)')}
${esc(entry.path)}${entry.startLine ? ':' + entry.startLine : ''}
`).join('') : `
${esc(t('dreaming.noEntries'))}
` return `
${esc(title)} ${entries.length}
${esc(subtitle)}
${items}
` } function renderSceneView(status, enabled, heroText, disabledAttr, nextRun) { const STARS = [ { top: 8, left: 15, size: 3, delay: 0 }, { top: 12, left: 72, size: 2, delay: 1.4 }, { top: 22, left: 35, size: 3, delay: 0.6 }, { top: 18, left: 88, size: 2, delay: 2.1 }, { top: 35, left: 8, size: 2, delay: 0.9 }, { top: 45, left: 92, size: 2, delay: 1.7 }, { top: 55, left: 25, size: 3, delay: 2.5 }, { top: 65, left: 78, size: 2, delay: 0.3 }, { top: 75, left: 45, size: 2, delay: 1.1 }, { top: 82, left: 60, size: 3, delay: 1.8 }, { top: 30, left: 55, size: 2, delay: 0.4 }, { top: 88, left: 18, size: 2, delay: 2.3 }, ] const starsHtml = STARS.map(s => `
`).join('') return `
${starsHtml}
z z Z
${esc(enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))}
${esc(t('dreaming.sceneTitle'))}
${esc(t('dreaming.sceneDesc'))}
${esc(heroText)}
${esc(`${t('dreaming.nextRun')}: ${nextRun}`)} ${esc(`${t('dreaming.timezone')}: ${status?.timezone || '—'}`)} ${esc(`${t('dreaming.memoryPath')}: ${status?.storePath || 'MEMORY.md'}`)}
${renderActionButtons(enabled, disabledAttr)}
${renderStatusHints()}
${esc(t('dreaming.sceneConstellation'))}
${esc(status?.shortTermCount ?? 0)}
${esc(t('dreaming.sceneSignals'))}
${esc(status?.totalSignalCount ?? 0)}
${esc(t('dreaming.scenePromotions'))}
${esc(status?.promotedTotal ?? 0)}
${esc(t('dreaming.sceneQueue'))}
${esc((status?.shortTermEntries || []).length)}
${renderStatCard(t('dreaming.promotedToday'), status?.promotedToday ?? 0)} ${renderStatCard(t('dreaming.grounded'), status?.groundedSignalCount ?? 0)} ${renderStatCard(t('dreaming.storageMode'), status?.storageMode || 'inline')} ${renderStatCard(t('dreaming.shortTerm'), status?.shortTermCount ?? 0, `${t('dreaming.memoryPath')}: ${status?.storePath || 'MEMORY.md'}`)} ${renderStatCard(t('dreaming.signals'), status?.totalSignalCount ?? 0, `${t('dreaming.diaryPath')}: ${_state.diaryPath || 'DREAMS.md'}`)}
${renderPhaseCard(t('dreaming.phaseLight'), status?.phases?.light || normalizePhase(null))} ${renderPhaseCard(t('dreaming.phaseDeep'), status?.phases?.deep || normalizePhase(null))} ${renderPhaseCard(t('dreaming.phaseRem'), status?.phases?.rem || normalizePhase(null))}
${renderDreamLane(t('dreaming.sceneQueue'), t('dreaming.entriesShortTerm'), status?.shortTermEntries || [], 'violet')} ${renderDreamLane(t('dreaming.sceneSignals'), t('dreaming.entriesSignals'), status?.signalEntries || [], 'cyan')} ${renderDreamLane(t('dreaming.scenePromotions'), t('dreaming.entriesPromoted'), status?.promotedEntries || [], 'amber')}
` } function renderDiaryView(status, enabled, heroText, disabledAttr) { const sections = parseDiarySections(_state.diaryContent) const diaryUnavailable = !_state.diarySupported let diaryBody = '' if (diaryUnavailable) { diaryBody = `
${esc(t('dreaming.diary'))}
${esc(t('dreaming.rpcUnsupported'))}
` } else { diaryBody = `
${esc(t('dreaming.diarySections'))}
${sections.length ? sections.map((section, idx) => `
${esc(`${t('dreaming.diarySection')} ${idx + 1}`)} ${esc(section.title)}
${esc(section.body.slice(0, 220) || section.title)}
`).join('') : `
${esc(t('dreaming.diaryEmpty'))}
${esc(t('dreaming.diaryEmptyHint'))}
`}
${esc(t('dreaming.diaryRaw'))}
${typeof _state.diaryContent === 'string' ? `
${esc(_state.diaryContent)}
` : `
${esc(t('dreaming.diaryEmpty'))}
${esc(t('dreaming.diaryEmptyHint'))}
`}
` } return `
${esc(t('dreaming.diary'))}
${esc(heroText)}
${esc(enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))} ${esc(`${t('dreaming.diaryPath')}: ${_state.diaryPath || 'DREAMS.md'}`)} ${!diaryUnavailable ? `${esc(`${t('dreaming.diarySections')}: ${sections.length}`)}` : ''}
${renderActionButtons(enabled, disabledAttr)}
${renderStatusHints()}
${diaryBody} ` } function bindEvents(page) { page.querySelectorAll('[data-dreaming-view]').forEach((tab) => { tab.addEventListener('click', () => { _state.view = tab.dataset.dreamingView || 'scene' renderPage(page) }) }) page.querySelector('#btn-dreaming-refresh')?.addEventListener('click', () => loadAll(page)) page.querySelector('#btn-dreaming-open-memory')?.addEventListener('click', () => navigate('/memory')) page.querySelector('#btn-dreaming-toggle')?.addEventListener('click', () => toggleDreaming()) page.querySelector('#btn-dreaming-backfill')?.addEventListener('click', () => runAction('doctor.memory.backfillDreamDiary', t('dreaming.backfillDone'))) page.querySelector('#btn-dreaming-reset-diary')?.addEventListener('click', async () => { const yes = await showConfirm(t('dreaming.confirmResetDiary')) if (!yes) return runAction('doctor.memory.resetDreamDiary', t('dreaming.resetDiaryDone')) }) page.querySelector('#btn-dreaming-clear-grounded')?.addEventListener('click', async () => { const yes = await showConfirm(t('dreaming.confirmClearGrounded')) if (!yes) return runAction('doctor.memory.resetGroundedShortTerm', t('dreaming.clearGroundedDone')) }) } function renderPage(page) { const status = _state.status const ready = wsClient.connected && wsClient.gatewayReady const enabled = status?.enabled === true const nextRun = formatNextRun(resolveNextRun(status)) const heroText = enabled ? t('dreaming.heroActive') : t('dreaming.heroIdle') const disabledAttr = _state.actionLoading || !ready ? 'disabled' : '' let body = '' if (_state.loading) { body = `
` } else if (!ready) { body = `
${esc(t('dreaming.gwConnecting'))}
${esc(t('dreaming.gwWait'))}
` } else if (_state.unsupported) { body = `
${esc(t('dreaming.loadFailed'))}
${esc(_state.error || t('dreaming.unsupportedHint'))}
${esc(t('dreaming.loadFailedHint'))}
` } else { body = renderViewTabs() + (_state.view === 'diary' ? renderDiaryView(status, enabled, heroText, disabledAttr) : renderSceneView(status, enabled, heroText, disabledAttr, nextRun)) } page.innerHTML = ` ${body} ` bindEvents(page) }