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(_state.diaryContent)}`
: `${t('dreaming.desc')}