mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: add Dreaming UI and gateway auto-fix startup flow
This commit is contained in:
@@ -698,8 +698,7 @@ pub fn save_openclaw_json(config: &Value) -> Result<(), String> {
|
||||
|
||||
/// 供其他模块复用:触发 Gateway 重载
|
||||
pub async fn do_reload_gateway(app: &tauri::AppHandle) -> Result<String, String> {
|
||||
let _ = app; // 预留扩展用
|
||||
reload_gateway().await
|
||||
reload_gateway_internal(Some(app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -4638,8 +4637,7 @@ async fn reload_gateway_via_http() -> Result<String, String> {
|
||||
/// 重载 Gateway 服务
|
||||
/// Windows/Linux: 优先尝试 HTTP 热重载(不重启进程)
|
||||
/// 如果 HTTP 重载失败,回退到 restart_service(会触发 Guardian 重启循环)
|
||||
#[tauri::command]
|
||||
pub async fn reload_gateway() -> Result<String, String> {
|
||||
async fn reload_gateway_internal(app: Option<&tauri::AppHandle>) -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let uid = get_uid()?;
|
||||
@@ -4658,23 +4656,29 @@ pub async fn reload_gateway() -> Result<String, String> {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// 优先尝试 HTTP 热重载(不影响现有连接)
|
||||
match reload_gateway_via_http().await {
|
||||
Ok(msg) => Ok(msg),
|
||||
Err(_) => {
|
||||
// HTTP 重载失败,回退到进程重启
|
||||
crate::commands::service::restart_service("ai.openclaw.gateway".into())
|
||||
.await
|
||||
.map(|_| "Gateway 已重启".to_string())
|
||||
crate::commands::service::restart_service(
|
||||
app.cloned().ok_or_else(|| "缺少 AppHandle,无法回退到 Gateway 进程重启".to_string())?,
|
||||
"ai.openclaw.gateway".into(),
|
||||
)
|
||||
.await
|
||||
.map(|_| "Gateway 已重启".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reload_gateway(app: tauri::AppHandle) -> Result<String, String> {
|
||||
reload_gateway_internal(Some(&app)).await
|
||||
}
|
||||
|
||||
/// 重启 Gateway 服务(与 reload_gateway 相同实现)
|
||||
#[tauri::command]
|
||||
pub async fn restart_gateway() -> Result<String, String> {
|
||||
reload_gateway().await
|
||||
pub async fn restart_gateway(app: tauri::AppHandle) -> Result<String, String> {
|
||||
reload_gateway_internal(Some(&app)).await
|
||||
}
|
||||
|
||||
/// 运行 openclaw doctor --fix 自动修复配置问题
|
||||
|
||||
@@ -3127,9 +3127,13 @@ pub async fn install_qqbot_plugin(
|
||||
"plugin-log",
|
||||
"QQ 插件安装完成;正在重启 Gateway 以加载插件(与官方文档一致)",
|
||||
);
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ =
|
||||
crate::commands::service::restart_service("ai.openclaw.gateway".into()).await;
|
||||
let _ = crate::commands::service::restart_service(
|
||||
app2,
|
||||
"ai.openclaw.gateway".into(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Ok("安装成功".into())
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const GUARDIAN_INTERVAL: Duration = Duration::from_secs(15);
|
||||
const GUARDIAN_RESTART_COOLDOWN: Duration = Duration::from_secs(60);
|
||||
const GUARDIAN_STABLE_WINDOW: Duration = Duration::from_secs(120);
|
||||
const GUARDIAN_MAX_AUTO_RESTART: u32 = 3;
|
||||
const GATEWAY_CONFIG_AUTO_FIX_COOLDOWN: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct GuardianRuntimeState {
|
||||
@@ -39,6 +40,12 @@ struct GuardianRuntimeState {
|
||||
give_up: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct GatewayConfigAutoFixState {
|
||||
last_attempt: Option<Instant>,
|
||||
in_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GuardianStatus {
|
||||
@@ -235,8 +242,147 @@ async fn wait_for_gateway_stopped(label: &str, timeout: Duration) -> Result<(),
|
||||
Err("Gateway 停止超时,请手动检查进程".into())
|
||||
}
|
||||
|
||||
fn gateway_err_log_path() -> std::path::PathBuf {
|
||||
crate::commands::openclaw_dir()
|
||||
.join("logs")
|
||||
.join("gateway.err.log")
|
||||
}
|
||||
|
||||
fn read_gateway_error_log_excerpt(max_bytes: usize) -> String {
|
||||
let bytes = match std::fs::read(gateway_err_log_path()) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let tail = if bytes.len() > max_bytes {
|
||||
&bytes[bytes.len() - max_bytes..]
|
||||
} else {
|
||||
&bytes[..]
|
||||
};
|
||||
String::from_utf8_lossy(tail).to_string()
|
||||
}
|
||||
|
||||
fn looks_like_gateway_config_mismatch(reason: &str) -> bool {
|
||||
let combined = format!("{}\n{}", reason, read_gateway_error_log_excerpt(8192)).to_lowercase();
|
||||
let has_invalid = combined.contains("config invalid") || combined.contains("invalid config");
|
||||
let has_newer_version = combined.contains("config was last written by a newer openclaw");
|
||||
let has_schema_mismatch = combined.contains("must not have additional properties")
|
||||
|| combined.contains("must not have additional property")
|
||||
|| combined.contains("plugins.entries.memory-core.config")
|
||||
|| combined.contains("additional properties");
|
||||
let mentions_doctor_fix = combined.contains("doctor --fix");
|
||||
(has_invalid && (has_schema_mismatch || mentions_doctor_fix))
|
||||
|| (has_newer_version && mentions_doctor_fix)
|
||||
}
|
||||
|
||||
static GUARDIAN_STATE: OnceLock<Arc<Mutex<GuardianRuntimeState>>> = OnceLock::new();
|
||||
static GUARDIAN_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
static GATEWAY_CONFIG_AUTO_FIX_STATE: OnceLock<Arc<Mutex<GatewayConfigAutoFixState>>> =
|
||||
OnceLock::new();
|
||||
|
||||
fn gateway_config_auto_fix_state() -> &'static Arc<Mutex<GatewayConfigAutoFixState>> {
|
||||
GATEWAY_CONFIG_AUTO_FIX_STATE
|
||||
.get_or_init(|| Arc::new(Mutex::new(GatewayConfigAutoFixState::default())))
|
||||
}
|
||||
|
||||
fn finish_gateway_config_auto_fix_attempt() {
|
||||
let mut state = gateway_config_auto_fix_state().lock().unwrap();
|
||||
state.in_progress = false;
|
||||
}
|
||||
|
||||
async fn try_auto_fix_gateway_config(
|
||||
reason: &str,
|
||||
app: Option<&tauri::AppHandle>,
|
||||
) -> Result<bool, String> {
|
||||
if !looks_like_gateway_config_mismatch(reason) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = gateway_config_auto_fix_state().lock().unwrap();
|
||||
if state.in_progress {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(last_attempt) = state.last_attempt {
|
||||
if last_attempt.elapsed() < GATEWAY_CONFIG_AUTO_FIX_COOLDOWN {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
state.in_progress = true;
|
||||
state.last_attempt = Some(Instant::now());
|
||||
}
|
||||
|
||||
guardian_log("检测到 Gateway 启动疑似配置失配,尝试自动执行 openclaw doctor --fix");
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_start",
|
||||
"检测到 Gateway 配置异常,正在自动执行 openclaw doctor --fix…",
|
||||
);
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
crate::utils::openclaw_command_async()
|
||||
.args(["doctor", "--fix"])
|
||||
.output(),
|
||||
)
|
||||
.await;
|
||||
|
||||
finish_gateway_config_auto_fix_attempt();
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if output.status.success() {
|
||||
let summary = if !stderr.is_empty() { stderr } else { stdout };
|
||||
if summary.is_empty() {
|
||||
guardian_log("自动执行 openclaw doctor --fix 成功");
|
||||
} else {
|
||||
guardian_log(&format!("自动执行 openclaw doctor --fix 成功: {summary}"));
|
||||
}
|
||||
Ok(true)
|
||||
} else {
|
||||
let summary = if !stderr.is_empty() { stderr } else { stdout };
|
||||
let detail = if summary.is_empty() {
|
||||
"doctor --fix 返回失败".to_string()
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
guardian_log(&format!("自动执行 openclaw doctor --fix 失败: {detail}"));
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
format!("已尝试自动执行 openclaw doctor --fix,但修复失败:{detail}"),
|
||||
);
|
||||
Err(format!(
|
||||
"检测到 Gateway 配置异常,已尝试自动执行 openclaw doctor --fix,但修复失败:{detail}"
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
guardian_log(&format!("自动执行 openclaw doctor --fix 失败: {err}"));
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
format!("已尝试自动执行 openclaw doctor --fix,但命令执行失败:{err}"),
|
||||
);
|
||||
Err(format!(
|
||||
"检测到 Gateway 配置异常,已尝试自动执行 openclaw doctor --fix,但命令执行失败:{err}"
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
guardian_log("自动执行 openclaw doctor --fix 超时 (30s)");
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
"已尝试自动执行 openclaw doctor --fix,但修复超时 (30s)",
|
||||
);
|
||||
Err("检测到 Gateway 配置异常,已尝试自动执行 openclaw doctor --fix,但修复超时 (30s)".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn guardian_state() -> &'static Arc<Mutex<GuardianRuntimeState>> {
|
||||
GUARDIAN_STATE.get_or_init(|| Arc::new(Mutex::new(GuardianRuntimeState::default())))
|
||||
@@ -258,6 +404,17 @@ fn guardian_log(message: &str) {
|
||||
.and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes()));
|
||||
}
|
||||
|
||||
fn emit_guardian_event(app: Option<&tauri::AppHandle>, kind: &str, message: impl Into<String>) {
|
||||
if let Some(app) = app {
|
||||
let payload = GuardianEventPayload {
|
||||
kind: kind.to_string(),
|
||||
auto_restart_count: 0,
|
||||
message: message.into(),
|
||||
};
|
||||
let _ = app.emit("guardian-event", payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn guardian_snapshot() -> GuardianStatus {
|
||||
let state = guardian_state().lock().unwrap();
|
||||
GuardianStatus {
|
||||
@@ -422,7 +579,7 @@ async fn guardian_tick(app: &tauri::AppHandle) {
|
||||
guardian_log(&format!(
|
||||
"检测到 Gateway 异常退出,后端守护开始自动重启 ({attempt}/{GUARDIAN_MAX_AUTO_RESTART})"
|
||||
));
|
||||
if let Err(err) = start_service_impl_internal("ai.openclaw.gateway").await {
|
||||
if let Err(err) = start_service_impl_internal("ai.openclaw.gateway", Some(app)).await {
|
||||
guardian_log(&format!("后端守护自动重启失败: {err}"));
|
||||
}
|
||||
}
|
||||
@@ -437,7 +594,55 @@ async fn guardian_tick(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_service_impl_internal(label: &str) -> Result<(), String> {
|
||||
async fn start_service_impl_internal(
|
||||
label: &str,
|
||||
app: Option<&tauri::AppHandle>,
|
||||
) -> Result<(), String> {
|
||||
match start_service_impl_internal_once(label).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => match try_auto_fix_gateway_config(&err, app).await {
|
||||
Ok(true) => {
|
||||
guardian_log("自动修复完成,准备重试启动 Gateway");
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_retry",
|
||||
"已自动修复配置,正在重试启动 Gateway…",
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
platform::cleanup_zombie_gateway_processes();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
match start_service_impl_internal_once(label).await {
|
||||
Ok(()) => {
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_success",
|
||||
"已自动修复配置并成功重试启动 Gateway。",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(retry_err) => {
|
||||
emit_guardian_event(
|
||||
app,
|
||||
"auto_fix_failure",
|
||||
format!(
|
||||
"已自动执行 openclaw doctor --fix 并重试启动 Gateway,但仍失败:{retry_err}"
|
||||
),
|
||||
);
|
||||
Err(format!(
|
||||
"{retry_err}\n(已自动执行 openclaw doctor --fix 并重试启动 Gateway)"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => Err(err),
|
||||
Err(fix_err) => Err(format!("{err}\n{fix_err}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_service_impl_internal_once(label: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
platform::start_service_impl(label)?;
|
||||
@@ -461,9 +666,12 @@ async fn stop_service_impl_internal(label: &str) -> Result<(), String> {
|
||||
wait_for_gateway_stopped(label, Duration::from_secs(10)).await
|
||||
}
|
||||
|
||||
async fn restart_service_impl_internal(label: &str) -> Result<(), String> {
|
||||
async fn restart_service_impl_internal(
|
||||
label: &str,
|
||||
app: Option<&tauri::AppHandle>,
|
||||
) -> Result<(), String> {
|
||||
stop_service_impl_internal(label).await?;
|
||||
start_service_impl_internal(label).await
|
||||
start_service_impl_internal(label, app).await
|
||||
}
|
||||
|
||||
pub fn start_backend_guardian(app: tauri::AppHandle) {
|
||||
@@ -1688,7 +1896,7 @@ pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_service(label: String) -> Result<(), String> {
|
||||
pub async fn start_service(app: tauri::AppHandle, label: String) -> Result<(), String> {
|
||||
let (running, pid) = current_gateway_runtime(&label).await;
|
||||
if running {
|
||||
ensure_owned_gateway_or_err(pid)?;
|
||||
@@ -1697,7 +1905,7 @@ pub async fn start_service(label: String) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
guardian_mark_manual_start();
|
||||
start_service_impl_internal(&label).await
|
||||
start_service_impl_internal(&label, Some(&app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1711,14 +1919,14 @@ pub async fn stop_service(label: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_service(label: String) -> Result<(), String> {
|
||||
pub async fn restart_service(app: tauri::AppHandle, label: String) -> Result<(), String> {
|
||||
let (running, pid) = current_gateway_runtime(&label).await;
|
||||
if running {
|
||||
ensure_owned_gateway_or_err(pid)?;
|
||||
}
|
||||
guardian_pause("manual restart");
|
||||
guardian_mark_manual_start();
|
||||
let result = restart_service_impl_internal(&label).await;
|
||||
let result = restart_service_impl_internal(&label, Some(&app)).await;
|
||||
guardian_resume("manual restart");
|
||||
result
|
||||
}
|
||||
|
||||
@@ -60,19 +60,29 @@ fn handle_menu_event(app: &AppHandle, id: &str) {
|
||||
}
|
||||
}
|
||||
"gateway_start" => {
|
||||
std::mem::drop(crate::commands::service::start_service(
|
||||
"ai.openclaw.gateway".into(),
|
||||
));
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = crate::commands::service::start_service(
|
||||
app2,
|
||||
"ai.openclaw.gateway".into(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
"gateway_stop" => {
|
||||
std::mem::drop(crate::commands::service::stop_service(
|
||||
"ai.openclaw.gateway".into(),
|
||||
));
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = crate::commands::service::stop_service("ai.openclaw.gateway".into()).await;
|
||||
});
|
||||
}
|
||||
"gateway_restart" => {
|
||||
std::mem::drop(crate::commands::service::restart_service(
|
||||
"ai.openclaw.gateway".into(),
|
||||
));
|
||||
let app2 = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _ = crate::commands::service::restart_service(
|
||||
app2,
|
||||
"ai.openclaw.gateway".into(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
|
||||
@@ -35,6 +35,7 @@ function NAV_ITEMS_FULL() { return [
|
||||
section: t('sidebar.sectionData'),
|
||||
items: [
|
||||
{ route: '/memory', label: t('sidebar.memory'), icon: 'memory' },
|
||||
{ route: '/dreaming', label: t('sidebar.dreaming'), icon: 'dreaming' },
|
||||
{ route: '/cron', label: t('sidebar.cron'), icon: 'clock' },
|
||||
{ route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
|
||||
]
|
||||
@@ -87,6 +88,7 @@ const ICONS = {
|
||||
about: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
||||
assistant: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/><path d="M18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/></svg>',
|
||||
security: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>',
|
||||
dreaming: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1111.2 3a7 7 0 109.8 9.8z"/><path d="M17 4l.8 1.7L19.5 6.5l-1.7.8L17 9l-.8-1.7-1.7-.8 1.7-.8L17 4z"/></svg>',
|
||||
skills: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>',
|
||||
channels: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
|
||||
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
|
||||
@@ -17,6 +17,7 @@ import security from './modules/security.js'
|
||||
import communication from './modules/communication.js'
|
||||
import channels from './modules/channels.js'
|
||||
import memory from './modules/memory.js'
|
||||
import dreaming from './modules/dreaming.js'
|
||||
import cron from './modules/cron.js'
|
||||
import usage from './modules/usage.js'
|
||||
import skills from './modules/skills.js'
|
||||
@@ -34,7 +35,7 @@ import engagement from './modules/engagement.js'
|
||||
const MODULES = {
|
||||
common, sidebar, instance, dashboard, services, settings,
|
||||
models, agents, agentDetail, gateway, security, communication, channels,
|
||||
memory, cron, usage, skills, chat, chatDebug, setup, about,
|
||||
memory, dreaming, cron, usage, skills, chat, chatDebug, setup, about,
|
||||
ext, logs, assistant, toast, modal, engagement,
|
||||
}
|
||||
|
||||
|
||||
71
src/locales/modules/dreaming.js
Normal file
71
src/locales/modules/dreaming.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { _ } from '../helper.js'
|
||||
|
||||
export default {
|
||||
title: _('梦境模式', 'Dreaming'),
|
||||
desc: _('查看 OpenClaw 4.9 的 Dreaming 状态、梦境日记与记忆沉淀情况', 'View OpenClaw 4.9 Dreaming status, dream diary, and memory consolidation'),
|
||||
viewScene: _('场景', 'Scene'),
|
||||
viewDiary: _('日记', 'Diary'),
|
||||
refresh: _('刷新', 'Refresh'),
|
||||
gwConnecting: _('Gateway 连接中...', 'Connecting to Gateway...'),
|
||||
gwWait: _('等待 Gateway 就绪后自动加载 Dreaming 数据', 'Will auto-load Dreaming data once Gateway is ready'),
|
||||
loadFailed: _('加载失败', 'Load failed'),
|
||||
loadFailedHint: _('需要 OpenClaw 2026.4.9+ 且启用 memory-core Dreaming 功能', 'Requires OpenClaw 2026.4.9+ with memory-core Dreaming enabled'),
|
||||
unsupportedHint: _('当前 Gateway 或记忆插件不支持 Dreaming 能力', 'The current Gateway or memory plugin does not support Dreaming'),
|
||||
toggleOn: _('启用梦境模式', 'Enable Dreaming'),
|
||||
toggleOff: _('关闭梦境模式', 'Disable Dreaming'),
|
||||
enabled: _('梦境模式已启用', 'Dreaming enabled'),
|
||||
disabled: _('梦境模式已关闭', 'Dreaming disabled'),
|
||||
toggleFailed: _('切换失败', 'Toggle failed'),
|
||||
backfill: _('回填梦境日记', 'Backfill Dream Diary'),
|
||||
resetDiary: _('重置梦境日记', 'Reset Dream Diary'),
|
||||
clearGrounded: _('清空 grounded 短期记忆', 'Clear grounded short-term memory'),
|
||||
backfillDone: _('梦境日记已回填', 'Dream diary backfilled'),
|
||||
resetDiaryDone: _('梦境日记已重置', 'Dream diary reset'),
|
||||
clearGroundedDone: _('grounded 短期记忆已清空', 'Grounded short-term memory cleared'),
|
||||
confirmResetDiary: _('确定要重置梦境日记吗?这会清空现有 DREAMS.md 内容。', 'Reset the dream diary? This will clear existing DREAMS.md content.'),
|
||||
confirmClearGrounded: _('确定要清空 grounded 短期记忆吗?', 'Clear grounded short-term memory?'),
|
||||
openMemory: _('打开记忆文件页', 'Open Memory Page'),
|
||||
heroActive: _('正在整理上下文、筛选线索、沉淀长期记忆', 'Consolidating context, filtering signals, and promoting long-term memory'),
|
||||
heroIdle: _('Dreaming 当前处于空闲状态,等待下一轮整理', 'Dreaming is idle and waiting for the next sweep'),
|
||||
sceneTitle: _('梦境星图', 'Dream Constellation'),
|
||||
sceneDesc: _('把短期记忆、grounded 信号与高频线索编织成长期记忆的可视化场景。', 'A visual scene where short-term memories, grounded signals, and recurring traces are woven into long-term memory.'),
|
||||
sceneConstellation: _('梦境星簇', 'Constellation'),
|
||||
sceneSignals: _('信号波纹', 'Signal Ripples'),
|
||||
scenePromotions: _('提升轨迹', 'Promotion Trail'),
|
||||
sceneQueue: _('待沉淀线索', 'Pending Traces'),
|
||||
statusEnabled: _('已启用', 'Enabled'),
|
||||
statusDisabled: _('未启用', 'Disabled'),
|
||||
nextRun: _('下一次运行', 'Next run'),
|
||||
timezone: _('时区', 'Timezone'),
|
||||
storageMode: _('存储模式', 'Storage mode'),
|
||||
promotedToday: _('今日提升', 'Promoted today'),
|
||||
promotedTotal: _('累计提升', 'Promoted total'),
|
||||
shortTerm: _('短期记忆', 'Short-term'),
|
||||
grounded: _('Grounded', 'Grounded'),
|
||||
signals: _('信号总数', 'Signals'),
|
||||
diary: _('梦境日记', 'Dream Diary'),
|
||||
diaryEmpty: _('还没有梦境日记', 'No dream diary yet'),
|
||||
diaryEmptyHint: _('可以先运行一次回填,或等待 Dreaming 周期自然写入', 'Try backfilling once, or wait for the next Dreaming cycle to write entries'),
|
||||
diaryLoadFailed: _('梦境日记加载失败', 'Dream diary failed to load'),
|
||||
diarySections: _('梦境片段', 'Diary Sections'),
|
||||
diaryRaw: _('原始 Markdown', 'Raw Markdown'),
|
||||
diarySection: _('片段', 'Section'),
|
||||
diaryPath: _('日记路径', 'Diary path'),
|
||||
memoryPath: _('记忆文件', 'Memory file'),
|
||||
phaseLight: _('Light', 'Light'),
|
||||
phaseDeep: _('Deep', 'Deep'),
|
||||
phaseRem: _('REM', 'REM'),
|
||||
cron: _('计划', 'Schedule'),
|
||||
notScheduled: _('未计划', 'Not scheduled'),
|
||||
entriesShortTerm: _('短期条目', 'Short-term entries'),
|
||||
entriesPromoted: _('已提升条目', 'Promoted entries'),
|
||||
entriesSignals: _('高信号条目', 'High-signal entries'),
|
||||
noEntries: _('暂无条目', 'No entries yet'),
|
||||
phaseHits: _('阶段命中', 'Phase hits'),
|
||||
groundedLed: _('grounded 主导', 'Grounded-led'),
|
||||
source: _('来源', 'Source'),
|
||||
retry: _('重试', 'Retry'),
|
||||
configUnavailable: _('无法获取在线配置快照,请稍后重试', 'Unable to load live config snapshot, please retry'),
|
||||
actionRunning: _('处理中...', 'Working...'),
|
||||
pluginUnsupported: _('当前记忆插件可能不支持 Dreaming 配置', 'The current memory plugin may not support Dreaming config'),
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
communication: _('通信与自动化', 'Communication', '通信與自動化', '通信と自動化', '통신 및 자동화', 'Truyền thông', 'Comunicación', 'Comunicação', 'Коммуникации', '', 'Kommunikation'),
|
||||
security: _('安全设置', 'Security', '安全設定', 'セキュリティ', '보안 설정', 'Bảo mật', 'Seguridad', 'Segurança', 'Безопасность', 'Sécurité', 'Sicherheit'),
|
||||
memory: _('记忆文件', 'Memory', '記憶檔案', 'メモリ', '메모리', 'Bộ nhớ', 'Memoria', 'Memória', 'Память', 'Mémoire', 'Speicher'),
|
||||
dreaming: _('梦境模式', 'Dreaming', '夢境模式', 'ドリーミング', '드리밍', 'Dreaming', 'Dreaming', 'Dreaming', 'Dreaming', 'Dreaming', 'Dreaming'),
|
||||
cron: _('定时任务', 'Cron Jobs', '定時任務', 'スケジュールタスク', '예약 작업', 'Tác vụ định kỳ', 'Tareas', 'Tarefas', 'Планировщик', 'Tâches planifiées', 'Geplante Aufgaben'),
|
||||
usage: _('使用情况', 'Usage', '使用情況', '使用状況', '사용 현황', 'Sử dụng', 'Uso', 'Uso', 'Использование', 'Utilisation', 'Nutzung'),
|
||||
skills: _('Skills', 'Skills'),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { version as APP_VERSION } from '../package.json'
|
||||
import { statusIcon } from './lib/icons.js'
|
||||
import { isForeignGatewayError, showGatewayConflictGuidance } from './lib/gateway-ownership.js'
|
||||
import { tryShowEngagement } from './components/engagement.js'
|
||||
import { toast } from './components/toast.js'
|
||||
import { initI18n, t } from './lib/i18n.js'
|
||||
|
||||
// 样式
|
||||
@@ -319,6 +320,7 @@ async function boot() {
|
||||
registerRoute('/agent-detail', () => import('./pages/agent-detail.js'))
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/dreaming', () => import('./pages/dreaming.js'))
|
||||
registerRoute('/skills', () => import('./pages/skills.js'))
|
||||
registerRoute('/security', () => import('./pages/security.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
@@ -412,6 +414,10 @@ async function boot() {
|
||||
import('@tauri-apps/api/event').then(async ({ listen }) => {
|
||||
await listen('guardian-event', (e) => {
|
||||
if (e.payload?.kind === 'give_up') showGuardianRecovery()
|
||||
else if (e.payload?.kind === 'auto_fix_start') toast(t('dashboard.fixing'), 'info')
|
||||
else if (e.payload?.kind === 'auto_fix_retry') toast(t('dashboard.fixDoneRestarting'), 'info')
|
||||
else if (e.payload?.kind === 'auto_fix_success') toast(t('dashboard.fixDoneRestarted'), 'success')
|
||||
else if (e.payload?.kind === 'auto_fix_failure') toast(String(e.payload?.message || t('dashboard.fixDoneRestartFail')).slice(0, 240), 'error')
|
||||
})
|
||||
}).catch(() => {})
|
||||
api.guardianStatus().then(status => {
|
||||
|
||||
651
src/pages/dreaming.js
Normal file
651
src/pages/dreaming.js
Normal file
@@ -0,0 +1,651 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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, '>')
|
||||
.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 async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
_page = page
|
||||
_state = createState()
|
||||
renderPage(page)
|
||||
await loadAll(page)
|
||||
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)
|
||||
} else {
|
||||
_state.status = null
|
||||
_state.error = errorMessage(statusResult.reason)
|
||||
_state.unsupported = isUnsupportedError(statusResult.reason)
|
||||
}
|
||||
|
||||
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)
|
||||
} 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) {
|
||||
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) {
|
||||
const message = errorMessage(e)
|
||||
if (isUnsupportedError(e) && !_state.toggleBlockedReason) {
|
||||
_state.toggleBlockedReason = t('dreaming.pluginUnsupported')
|
||||
}
|
||||
toast(`${t('dreaming.toggleFailed')}: ${message}`, 'error')
|
||||
_state.actionLoading = false
|
||||
renderPage(_page)
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatCard(label, value, meta = '') {
|
||||
return `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">${esc(label)}</span></div>
|
||||
<div class="stat-card-value">${esc(value)}</div>
|
||||
${meta ? `<div class="stat-card-meta">${esc(meta)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="config-section" style="margin:0">
|
||||
<div class="config-section-title" style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||
<span>${esc(title)}</span>
|
||||
<span class="badge${phase.enabled ? ' badge-success' : ''}">${esc(phase.enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))}</span>
|
||||
</div>
|
||||
<div class="form-hint">${esc(meta || t('dreaming.notScheduled'))}</div>
|
||||
${details ? `<div style="margin-top:8px;font-size:12px;color:var(--text-secondary)">${esc(details)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderEntries(title, entries) {
|
||||
const content = entries.length
|
||||
? entries.slice(0, 8).map((entry) => `
|
||||
<div style="padding:10px 0;border-bottom:1px solid var(--border-primary)">
|
||||
<div style="font-size:13px;color:var(--text-primary);line-height:1.6">${esc(entry.snippet || '(empty)')}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--text-secondary)">${esc(entry.path)}${entry.startLine ? ':' + entry.startLine : ''}${entry.endLine && entry.endLine !== entry.startLine ? '-' + entry.endLine : ''}</div>
|
||||
<div style="margin-top:4px;font-size:12px;color:var(--text-tertiary)">
|
||||
${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(' · '))}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: `<div class="form-hint">${esc(t('dreaming.noEntries'))}</div>`
|
||||
|
||||
return `
|
||||
<div class="config-section" style="margin:0">
|
||||
<div class="config-section-title">${esc(title)}</div>
|
||||
${content}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderActionButtons(enabled, disabledAttr) {
|
||||
const toggleText = enabled ? t('dreaming.toggleOff') : t('dreaming.toggleOn')
|
||||
return `
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm ${enabled ? 'btn-warning' : 'btn-primary'}" id="btn-dreaming-toggle" ${disabledAttr}>${esc(_state.actionLoading ? t('dreaming.actionRunning') : toggleText)}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-dreaming-backfill" ${disabledAttr}>${esc(t('dreaming.backfill'))}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-dreaming-reset-diary" ${disabledAttr}>${esc(t('dreaming.resetDiary'))}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-dreaming-clear-grounded" ${disabledAttr}>${esc(t('dreaming.clearGrounded'))}</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderStatusHints() {
|
||||
return `
|
||||
${_state.toggleBlockedReason ? `<div class="form-hint" style="margin-top:10px">${esc(_state.toggleBlockedReason)}</div>` : ''}
|
||||
${_state.error && !_state.unsupported ? `<div style="margin-top:12px;color:var(--warning)">${esc(_state.error)}</div>` : ''}
|
||||
`
|
||||
}
|
||||
|
||||
function renderViewTabs() {
|
||||
return `
|
||||
<div class="tab-bar" style="margin-bottom:var(--space-lg)">
|
||||
<div class="tab${_state.view === 'scene' ? ' active' : ''}" data-dreaming-view="scene">${esc(t('dreaming.viewScene'))}</div>
|
||||
<div class="tab${_state.view === 'diary' ? ' active' : ''}" data-dreaming-view="diary">${esc(t('dreaming.viewDiary'))}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderDreamLane(title, subtitle, entries, accent) {
|
||||
const tones = {
|
||||
violet: { border: 'rgba(168,85,247,0.35)', bg: 'linear-gradient(180deg, rgba(91,33,182,0.22), rgba(30,41,59,0.6))', glow: 'rgba(168,85,247,0.22)' },
|
||||
cyan: { border: 'rgba(34,211,238,0.35)', bg: 'linear-gradient(180deg, rgba(8,145,178,0.18), rgba(15,23,42,0.58))', glow: 'rgba(34,211,238,0.18)' },
|
||||
amber: { border: 'rgba(251,191,36,0.35)', bg: 'linear-gradient(180deg, rgba(180,83,9,0.18), rgba(30,41,59,0.58))', glow: 'rgba(251,191,36,0.18)' },
|
||||
}
|
||||
const tone = tones[accent] || tones.violet
|
||||
const items = entries.length
|
||||
? entries.slice(0, 4).map((entry, idx) => `
|
||||
<div style="display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:${idx === entries.slice(0, 4).length - 1 ? 'none' : '1px solid rgba(255,255,255,0.08)'}">
|
||||
<div style="width:9px;height:9px;border-radius:999px;background:${tone.border};box-shadow:0 0 12px ${tone.glow};margin-top:6px;flex-shrink:0"></div>
|
||||
<div style="min-width:0">
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--text-primary)">${esc(entry.snippet || '(empty)')}</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:var(--text-tertiary)">${esc(entry.path)}${entry.startLine ? ':' + entry.startLine : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: `<div class="form-hint">${esc(t('dreaming.noEntries'))}</div>`
|
||||
return `
|
||||
<div class="config-section" style="margin:0;border:1px solid ${tone.border};background:${tone.bg};backdrop-filter:blur(6px)">
|
||||
<div class="config-section-title">${esc(title)}</div>
|
||||
<div class="form-hint" style="margin-bottom:8px">${esc(subtitle)}</div>
|
||||
${items}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderSceneView(status, enabled, heroText, disabledAttr, nextRun) {
|
||||
const stars = [
|
||||
{ top: '14%', left: '8%', size: 4, opacity: 0.8 },
|
||||
{ top: '22%', left: '30%', size: 6, opacity: 0.55 },
|
||||
{ top: '18%', left: '64%', size: 5, opacity: 0.75 },
|
||||
{ top: '32%', left: '74%', size: 3, opacity: 0.9 },
|
||||
{ top: '58%', left: '18%', size: 5, opacity: 0.65 },
|
||||
{ top: '66%', left: '54%', size: 4, opacity: 0.7 },
|
||||
{ top: '72%', left: '82%', size: 6, opacity: 0.5 },
|
||||
]
|
||||
return `
|
||||
<div style="position:relative;overflow:hidden;border-radius:22px;padding:24px;background:radial-gradient(circle at 20% 10%, rgba(139,92,246,0.42), rgba(15,23,42,0.94) 52%), linear-gradient(135deg, #0f172a 0%, #1e1b4b 55%, #312e81 100%);color:#e2e8f0;box-shadow:0 24px 64px rgba(15,23,42,0.35);margin-bottom:var(--space-lg)">
|
||||
${stars.map((star) => `<div style="position:absolute;top:${star.top};left:${star.left};width:${star.size}px;height:${star.size}px;border-radius:999px;background:rgba(255,255,255,${star.opacity});box-shadow:0 0 16px rgba(255,255,255,0.28)"></div>`).join('')}
|
||||
<div style="position:absolute;top:22px;right:28px;width:118px;height:118px;border-radius:999px;background:radial-gradient(circle at 35% 35%, rgba(255,255,255,0.98), rgba(224,231,255,0.92) 38%, rgba(196,181,253,0.56) 62%, rgba(99,102,241,0.16) 100%);box-shadow:0 0 32px rgba(196,181,253,0.45), 0 0 88px rgba(99,102,241,0.18)"></div>
|
||||
<div style="position:relative;display:flex;justify-content:space-between;gap:18px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div style="max-width:620px">
|
||||
<div class="badge${enabled ? ' badge-success' : ''}" style="margin-bottom:10px">${esc(enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))}</div>
|
||||
<div style="font-size:28px;font-weight:700;letter-spacing:-0.02em;margin-bottom:10px">${esc(t('dreaming.sceneTitle'))}</div>
|
||||
<div style="font-size:14px;line-height:1.8;color:rgba(226,232,240,0.88);max-width:560px">${esc(t('dreaming.sceneDesc'))}</div>
|
||||
<div style="margin-top:12px;font-size:14px;line-height:1.8;color:rgba(255,255,255,0.92)">${esc(heroText)}</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px">
|
||||
<div style="padding:8px 12px;border-radius:999px;background:rgba(255,255,255,0.08);font-size:12px">${esc(`${t('dreaming.nextRun')}: ${nextRun}`)}</div>
|
||||
<div style="padding:8px 12px;border-radius:999px;background:rgba(255,255,255,0.08);font-size:12px">${esc(`${t('dreaming.timezone')}: ${status?.timezone || '—'}`)}</div>
|
||||
<div style="padding:8px 12px;border-radius:999px;background:rgba(255,255,255,0.08);font-size:12px">${esc(`${t('dreaming.memoryPath')}: ${status?.storePath || 'MEMORY.md'}`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;z-index:1;display:flex;flex-direction:column;gap:10px;align-items:flex-end;max-width:420px">
|
||||
${renderActionButtons(enabled, disabledAttr)}
|
||||
</div>
|
||||
</div>
|
||||
${renderStatusHints()}
|
||||
<div style="position:relative;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:20px">
|
||||
<div style="padding:14px;border-radius:16px;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px)"><div style="font-size:12px;color:rgba(226,232,240,0.72)">${esc(t('dreaming.sceneConstellation'))}</div><div style="font-size:24px;font-weight:700;margin-top:4px">${esc(status?.shortTermCount ?? 0)}</div></div>
|
||||
<div style="padding:14px;border-radius:16px;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px)"><div style="font-size:12px;color:rgba(226,232,240,0.72)">${esc(t('dreaming.sceneSignals'))}</div><div style="font-size:24px;font-weight:700;margin-top:4px">${esc(status?.totalSignalCount ?? 0)}</div></div>
|
||||
<div style="padding:14px;border-radius:16px;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px)"><div style="font-size:12px;color:rgba(226,232,240,0.72)">${esc(t('dreaming.scenePromotions'))}</div><div style="font-size:24px;font-weight:700;margin-top:4px">${esc(status?.promotedTotal ?? 0)}</div></div>
|
||||
<div style="padding:14px;border-radius:16px;background:rgba(255,255,255,0.08);backdrop-filter:blur(8px)"><div style="font-size:12px;color:rgba(226,232,240,0.72)">${esc(t('dreaming.sceneQueue'))}</div><div style="font-size:24px;font-weight:700;margin-top:4px">${esc((status?.shortTermEntries || []).length)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||||
${renderStatCard(t('dreaming.nextRun'), nextRun)}
|
||||
${renderStatCard(t('dreaming.timezone'), status?.timezone || '—')}
|
||||
${renderStatCard(t('dreaming.storageMode'), status?.storageMode || 'inline')}
|
||||
${renderStatCard(t('dreaming.promotedToday'), status?.promotedToday ?? 0)}
|
||||
${renderStatCard(t('dreaming.promotedTotal'), status?.promotedTotal ?? 0)}
|
||||
${renderStatCard(t('dreaming.shortTerm'), status?.shortTermCount ?? 0, `${t('dreaming.memoryPath')}: ${status?.storePath || 'MEMORY.md'}`)}
|
||||
${renderStatCard(t('dreaming.grounded'), status?.groundedSignalCount ?? 0)}
|
||||
${renderStatCard(t('dreaming.signals'), status?.totalSignalCount ?? 0, `${t('dreaming.diaryPath')}: ${_state.diaryPath || 'DREAMS.md'}`)}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:var(--space-md);margin-bottom:var(--space-lg)">
|
||||
${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))}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:var(--space-md)">
|
||||
${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')}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderDiaryView(status, enabled, heroText, disabledAttr) {
|
||||
const sections = parseDiarySections(_state.diaryContent)
|
||||
return `
|
||||
<div class="config-section" style="margin-bottom:var(--space-lg);background:linear-gradient(180deg, rgba(99,102,241,0.08), rgba(15,23,42,0.02));border:1px solid rgba(99,102,241,0.14)">
|
||||
<div style="display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div style="max-width:620px">
|
||||
<div class="config-section-title">${esc(t('dreaming.diary'))}</div>
|
||||
<div style="font-size:14px;line-height:1.8;color:var(--text-secondary)">${esc(heroText)}</div>
|
||||
<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">
|
||||
<div class="badge${enabled ? ' badge-success' : ''}">${esc(enabled ? t('dreaming.statusEnabled') : t('dreaming.statusDisabled'))}</div>
|
||||
<div class="badge">${esc(`${t('dreaming.diaryPath')}: ${_state.diaryPath || 'DREAMS.md'}`)}</div>
|
||||
<div class="badge">${esc(`${t('dreaming.diarySections')}: ${sections.length}`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderActionButtons(enabled, disabledAttr)}
|
||||
</div>
|
||||
${renderStatusHints()}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--space-md)">
|
||||
<div class="config-section" style="margin:0">
|
||||
<div class="config-section-title">${esc(t('dreaming.diarySections'))}</div>
|
||||
${sections.length
|
||||
? sections.map((section, idx) => `
|
||||
<div style="padding:14px 0;border-bottom:${idx === sections.length - 1 ? 'none' : '1px solid var(--border-primary)'}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span class="badge${idx === 0 ? ' badge-success' : ''}">${esc(`${t('dreaming.diarySection')} ${idx + 1}`)}</span>
|
||||
<span style="font-weight:600;color:var(--text-primary)">${esc(section.title)}</span>
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.7;color:var(--text-secondary)">${esc(section.body.slice(0, 220) || section.title)}</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: `<div class="form-hint" style="line-height:1.8">${esc(t('dreaming.diaryEmpty'))}<br>${esc(t('dreaming.diaryEmptyHint'))}</div>`}
|
||||
</div>
|
||||
|
||||
<div class="config-section" style="margin:0">
|
||||
<div class="config-section-title">${esc(t('dreaming.diaryRaw'))}</div>
|
||||
${typeof _state.diaryContent === 'string'
|
||||
? `<pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-secondary);border-radius:var(--radius);padding:var(--space-md);font-size:12px;line-height:1.7;max-height:560px;overflow:auto">${esc(_state.diaryContent)}</pre>`
|
||||
: `<div class="form-hint" style="line-height:1.8">${esc(t('dreaming.diaryEmpty'))}<br>${esc(t('dreaming.diaryEmptyHint'))}</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="stat-card loading-placeholder" style="height:120px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:220px;margin-top:var(--space-md)"></div>
|
||||
`
|
||||
} else if (!ready) {
|
||||
body = `
|
||||
<div class="config-section">
|
||||
<div style="color:var(--text-tertiary);margin-bottom:8px">${esc(t('dreaming.gwConnecting'))}</div>
|
||||
<div class="form-hint">${esc(t('dreaming.gwWait'))}</div>
|
||||
</div>
|
||||
`
|
||||
} else if (_state.unsupported) {
|
||||
body = `
|
||||
<div class="config-section" style="border-left:3px solid var(--warning)">
|
||||
<div class="config-section-title">${esc(t('dreaming.loadFailed'))}</div>
|
||||
<div style="color:var(--warning);line-height:1.7">${esc(_state.error || t('dreaming.unsupportedHint'))}</div>
|
||||
<div class="form-hint" style="margin-top:8px">${esc(t('dreaming.loadFailedHint'))}</div>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
body = renderViewTabs() + (_state.view === 'diary'
|
||||
? renderDiaryView(status, enabled, heroText, disabledAttr)
|
||||
: renderSceneView(status, enabled, heroText, disabledAttr, nextRun))
|
||||
}
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">${t('dreaming.title')}</h1>
|
||||
<p class="page-desc">${t('dreaming.desc')}</p>
|
||||
<div class="page-actions" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-dreaming-refresh">${icon('refresh-cw', 14)} ${t('dreaming.refresh')}</button>
|
||||
<button class="btn btn-sm btn-secondary" id="btn-dreaming-open-memory">${t('dreaming.openMemory')}</button>
|
||||
</div>
|
||||
</div>
|
||||
${body}
|
||||
`
|
||||
|
||||
bindEvents(page)
|
||||
}
|
||||
Reference in New Issue
Block a user