From a64b57345d4749de11c83418ee4b9cb0f17d3037 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 12 Aug 2026 18:56:23 +0800 Subject: [PATCH] emrg: GUI task/template management in settings (rant 2026-08-12T18:23:15 P3) --- Agent.md | 4 +- emrg/gui/daemon_client.js | 8 + emrg/gui/main.js | 57 +++++ emrg/gui/preload.js | 8 + emrg/gui/renderer/css/components.css | 69 ++++++ emrg/gui/renderer/index.html | 58 ++++++ emrg/gui/renderer/js/app.js | 1 + emrg/gui/renderer/js/dialogs.js | 301 +++++++++++++++++++++++++++ emrg/gui/renderer/js/i18n.js | 68 ++++++ emrg/gui/test/daemon_client.test.js | 52 +++++ emrg/gui/test/renderer.smoke.test.js | 162 ++++++++++++++ 11 files changed, 786 insertions(+), 2 deletions(-) diff --git a/Agent.md b/Agent.md index 419ccfcf..97cba068 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (229: 44 daemon_client + 19 conn-manager + 22 app-commands + 107 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (744) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (229: 44 daemon_client + 19 conn-manager + 22 app-commands + 107 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 518656d7..996e9158 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -69,6 +69,14 @@ const RESPONSE_TYPES = { github_connect_web: "github_connect_web_result", // Stage 2b:device flow(daemon.py github_connect_web) list_files: "files_list", // 右栏工作区面板 P1:目录树(daemon.py list_files) read_file: "file_content", // 右栏工作区面板 P1:文件查看器(daemon.py read_file) + // rant 2026-08-12T18:23:15 P2/P3:任务 + 自定义类型 CRUD(daemon.py task_create 等) + task_create: "task_result", + task_update: "task_result", + task_delete: "task_result", + task_template_list: "templates_list", + task_template_create: "template_result", + task_template_update: "template_result", + task_template_delete: "template_result", }; class DaemonClient { diff --git a/emrg/gui/main.js b/emrg/gui/main.js index ed380e6c..1ae89c60 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -610,6 +610,63 @@ vision = false return frame; }); + // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── + ipcMain.handle("emrg:taskCreate", async (_e, payload) => { + const { name, type, project, interval, enabled, repo, description } = payload || {}; + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); + if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); + const frame = await requireConn().sendCommandAndWait("task_create", { + name: name.trim(), type: type.trim(), project: project.trim(), + interval, enabled, repo, description, + }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskUpdate", async (_e, payload) => { + const { name, ...fields } = payload || {}; + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + const frame = await requireConn().sendCommandAndWait("task_update", { name: name.trim(), ...fields }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskDelete", async (_e, { name }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + const frame = await requireConn().sendCommandAndWait("task_delete", { name: name.trim() }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateList", async () => { + const frame = await requireConn().sendCommandAndWait("task_template_list", {}, 5000); + return frame.templates || []; + }); + + ipcMain.handle("emrg:taskTemplateCreate", async (_e, { name, prompt }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + if (typeof prompt !== "string" || !prompt.trim()) throw new Error("invalid template prompt"); + const frame = await requireConn().sendCommandAndWait("task_template_create", { name: name.trim(), prompt }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateUpdate", async (_e, { name, prompt }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + if (typeof prompt !== "string" || !prompt.trim()) throw new Error("invalid template prompt"); + const frame = await requireConn().sendCommandAndWait("task_template_update", { name: name.trim(), prompt }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateDelete", async (_e, { name }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + const frame = await requireConn().sendCommandAndWait("task_template_delete", { name: name.trim() }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + ipcMain.handle("emrg:sendRant", async (_e, { message, project = "" } = {}) => { // GUI / 指令 P4:/rant — 提交反馈到演化系统(daemon rant 协议,字段序与 rants.jsonl 一致) if (typeof message !== "string" || !message.trim()) throw new Error("invalid rant message"); diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index c10bfc33..9cb2cbd3 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -36,6 +36,14 @@ const api = { removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), + // Task/template CRUD (rant 2026-08-12T18:23:15 P3) + taskCreate: (payload) => ipcRenderer.invoke("emrg:taskCreate", payload), + taskUpdate: (payload) => ipcRenderer.invoke("emrg:taskUpdate", payload), + taskDelete: (payload) => ipcRenderer.invoke("emrg:taskDelete", payload), + taskTemplateList: () => ipcRenderer.invoke("emrg:taskTemplateList"), + taskTemplateCreate: (payload) => ipcRenderer.invoke("emrg:taskTemplateCreate", payload), + taskTemplateUpdate: (payload) => ipcRenderer.invoke("emrg:taskTemplateUpdate", payload), + taskTemplateDelete: (payload) => ipcRenderer.invoke("emrg:taskTemplateDelete", payload), sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload), githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 054a1a52..f597aab5 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -745,6 +745,75 @@ dialog::backdrop { .model-form-vision input { accent-color: var(--accent); } + +/* 定时任务管理(rant 2026-08-12T18:23:15 P3:GUI 任务/自定义类型 CRUD) */ +.task-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 220px; + overflow-y: auto; + margin-top: 4px; +} +.task-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); +} +.task-name { + font-weight: 600; + flex-shrink: 0; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-hint { + font-size: var(--fs-aux); + color: var(--text-3); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-badge { + font-size: var(--fs-aux); + color: var(--text-3); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 4px; + flex-shrink: 0; +} +.task-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.task-empty { + font-size: var(--fs-aux); + color: var(--text-3); + text-align: center; + padding: var(--sp-2); +} +.task-form { + margin-top: 8px; + padding: var(--sp-3); + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.task-form select, +.task-form textarea { + width: 100%; + margin-top: 2px; + padding: 6px 10px; + font-size: var(--fs-secondary); +} + .theme-options { display: flex; gap: var(--sp-2); diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index 2548b7f2..40e8cdbb 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -179,6 +179,64 @@

设置

+
+
定时任务
+
管理定时任务与自定义任务类型(内置类型只读)
+ +
+
+ + +
+ + + + + + +
关于
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index cbe56e44..38c6c65d 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -1559,6 +1559,7 @@ const App = (() => { Dialogs.initRenameDialog(); Dialogs.initGithubSection(); // Windows GCM rant Stage 2:设置页 GitHub 连接 Dialogs.initDeviceDialog(); // Windows GCM rant Stage 2b:device flow 对话框 + Dialogs.initTaskManagement(); // rant 18:23:15 P3:定时任务/自定义类型管理 initGithubBanner(); // Windows GCM rant Stage 2:演化需 GitHub 但未认证时的连接横幅 initModelSwitcher(); initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式 diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 45c556e0..bd01028d 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -232,6 +232,11 @@ const Dialogs = (() => { } catch { /* 元素缺失(测试桩)时忽略 */ } // Windows GCM rant Stage 2:GitHub 连接状态随设置面板打开时刷新 await refreshGithubStatus(); + // 定时任务管理(rant 2026-08-12T18:23:15 P3):任务/类型列表随设置面板打开时刷新 + try { + await loadTaskMeta(); + await renderTaskList(); + } catch { /* 元素缺失(测试桩)时忽略 */ } // Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows // a one-time non-intrusive line when a newer release exists. // rant 2026-08-11T09:18:16:手动"检查更新"按钮(force 重新检查) @@ -611,6 +616,298 @@ const Dialogs = (() => { } catch { /* boot-time daemon may not be ready — silent */ } } + // ── 定时任务管理(rant 2026-08-12T18:23:15 P3:GUI 任务/自定义类型 CRUD) ── + // 决策点执行:①内置类型只读 ②被任务引用的自定义类型拒绝删除 ③项目仅限已注册 + // ④热重载(daemon apply_tasks)⑤间隔 ≥60 ⑥内置模板只读。 + let taskTypes = []; // [{name, builtin}] — 类型下拉选项(内置 + 自定义) + let taskProjects = []; // listProjects 结果 — 项目下拉仅已注册项目(决策点③) + let editingTask = null; // 正在编辑的任务名(null = 新增;daemon 以 name 定位 → 名称不可改) + let editingTemplate = null; // 正在编辑的自定义类型名(null = 新增;名称不可改) + + async function loadTaskMeta() { + try { + const templates = await window.emrg.taskTemplateList(); + taskTypes = Array.isArray(templates) + ? templates.map((t) => ({ name: t.name, builtin: Boolean(t.builtin) })) + : []; + } catch { taskTypes = []; } + try { + const projects = await window.emrg.listProjects(); + taskProjects = Array.isArray(projects) ? projects : []; + } catch { taskProjects = []; } + } + + async function renderTaskList() { + const list = $("task-list"); + if (!list) return; // 元素缺失(测试桩)时忽略 + list.innerHTML = ""; + let tasks = []; + try { + tasks = await window.emrg.listTasks(); + } catch (e) { + list.innerHTML = `
${_t("settings.taskEmpty")}
`; + return; + } + if (!tasks || tasks.length === 0) { + list.innerHTML = `
${_t("settings.taskEmpty")}
`; + return; + } + for (const t of tasks) { + const row = el("div", { class: "task-row" }); + row.appendChild(el("span", { class: "task-name" }, t.name || "?")); + row.appendChild(el("span", { class: "task-badge" }, t.type || "evolution")); + const cfg = (t.config && typeof t.config === "object") ? t.config : {}; + const hints = []; + if (cfg.project) hints.push(cfg.project); + if (t.interval != null) hints.push(_t("app.taskInterval", { n: t.interval ?? "-" })); + if (t.enabled === false) hints.push(_t("app.taskDisabled")); + row.appendChild(el("span", { class: "task-hint" }, hints.join(" · "))); + const actions = el("span", { class: "task-actions" }); + // 触发(复用 /trigger 语义) + const trigBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.taskTrigger") }, _t("settings.taskTrigger")); + trigBtn.addEventListener("click", async () => { + try { + const res = await window.emrg.triggerTask({ name: t.name }); + if (res && res.error) Chat.addSystemMessage(_t("app.triggerFailed", { msg: res.error })); + else Chat.addSystemMessage(_t("app.triggered", { n: t.name })); + } catch (e) { + Chat.addSystemMessage(_t("app.triggerFailed", { msg: e.message })); + } + }); + actions.appendChild(trigBtn); + // 编辑(表单预填 → taskUpdate) + const editBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.taskEdit") }, _t("settings.taskEdit")); + editBtn.addEventListener("click", () => openTaskForm(t)); + actions.appendChild(editBtn); + // 删除(确认弹窗 → taskDelete → 热重载由 daemon apply_tasks 完成) + const delBtn = el("button", { type: "button", class: "model-action-btn danger", title: _t("settings.taskDelete") }, _t("settings.taskDelete")); + delBtn.addEventListener("click", () => { + showConfirm(_t("settings.taskDelete"), _t("settings.taskDeleteConfirm", { name: t.name }), { + okText: _t("settings.taskDelete"), + danger: true, + onOk: async () => { + try { + await window.emrg.taskDelete({ name: t.name }); + Chat.addSystemMessage(_t("settings.taskDeleted")); + await renderTaskList(); + } catch (e) { + Chat.addSystemMessage(_t("app.tasksFailed", { msg: e.message })); + } + }, + }); + }); + actions.appendChild(delBtn); + row.appendChild(actions); + list.appendChild(row); + } + } + + function openTaskForm(task = null) { + editingTask = task ? task.name : null; + $("task-form-name").value = task ? task.name : ""; + $("task-form-name").disabled = Boolean(task); // 决策点:daemon 以 name 定位 → 不可改名 + // 类型下拉(内置 + 自定义;决策点⑥内置模板只读体现在模板管理区) + const typeSel = $("task-form-type"); + typeSel.innerHTML = ""; + for (const tp of taskTypes) { + const opt = el("option", { value: tp.name }, tp.name); + typeSel.appendChild(opt); + } + const curType = (task && task.type) || (taskTypes[0] && taskTypes[0].name) || "evolution"; + typeSel.value = taskTypes.some((tp) => tp.name === curType) ? curType : (taskTypes[0] ? taskTypes[0].name : "evolution"); + // 项目下拉(决策点③:仅已注册项目) + const projSel = $("task-form-project"); + projSel.innerHTML = ""; + for (const p of taskProjects) { + const pv = p.name || p.path || ""; + projSel.appendChild(el("option", { value: pv }, pv)); + } + const cfg = (task && task.config && typeof task.config === "object") ? task.config : {}; + const curProj = cfg.project || (taskProjects[0] ? taskProjects[0].name || taskProjects[0].path || "" : ""); + projSel.value = taskProjects.some((p) => (p.name || p.path || "") === curProj) + ? curProj + : (taskProjects[0] ? taskProjects[0].name || taskProjects[0].path || "" : ""); + $("task-form-interval").value = task && task.interval != null ? task.interval : 1800; + $("task-form-enabled").checked = task ? task.enabled !== false : true; + $("task-form-repo").value = cfg.repo || ""; + $("task-form").classList.remove("hidden"); + $("task-form-name").focus(); + } + + function closeTaskForm() { + $("task-form").classList.add("hidden"); + editingTask = null; + $("task-form-name").disabled = false; + } + + async function saveTaskForm() { + const name = $("task-form-name").value.trim(); + if (!name) { + showConfirm(_t("dlg.stepTitle"), _t("dlg.nameRequiredBody"), { okText: _t("dlg.gotIt"), danger: false }); + return; + } + // 决策点⑤:间隔最小值 60s(客户端先行校验,daemon 兜底) + const interval = parseInt($("task-form-interval").value, 10); + if (!Number.isFinite(interval) || interval < 60) { + showConfirm(_t("dlg.stepTitle"), _t("settings.taskIntervalInvalid"), { okText: _t("dlg.gotIt"), danger: false }); + return; + } + const payload = { + name, + type: $("task-form-type").value, + project: $("task-form-project").value, + interval, + enabled: $("task-form-enabled").checked, + repo: $("task-form-repo").value.trim() || undefined, + }; + try { + if (editingTask === null) await window.emrg.taskCreate(payload); + else await window.emrg.taskUpdate(payload); + Chat.addSystemMessage(_t("settings.taskSaved")); + closeTaskForm(); + await renderTaskList(); + } catch (e) { + Chat.addSystemMessage(_t("app.tasksFailed", { msg: e.message })); + } + } + + // ── 自定义类型管理(决策点①⑥:内置只读;决策点②:被引用拒绝删除) ── + async function renderTemplateList() { + const wrap = $("task-template-list"); + if (!wrap) return; + wrap.innerHTML = ""; + let templates = []; + try { + templates = await window.emrg.taskTemplateList(); + } catch (e) { + wrap.innerHTML = `
${_t("settings.templateEmpty")}
`; + return; + } + if (!templates || templates.length === 0) { + wrap.innerHTML = `
${_t("settings.templateEmpty")}
`; + return; + } + for (const t of templates) { + const row = el("div", { class: "task-row" }); + row.appendChild(el("span", { class: "task-name" }, t.name)); + row.appendChild(el("span", { class: "task-badge" }, t.builtin ? _t("settings.taskBuiltin") : _t("settings.taskCustom"))); + const body = t.prompt || t.template || ""; + row.appendChild(el("span", { class: "task-hint" }, body.length > 60 ? body.slice(0, 60) + "…" : body)); + // 内置只读(决策点①⑥):不渲染任何操作按钮 + if (!t.builtin) { + const actions = el("span", { class: "task-actions" }); + const editBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.templateEdit") }, _t("settings.templateEdit")); + editBtn.addEventListener("click", () => openTemplateForm(t)); + actions.appendChild(editBtn); + const delBtn = el("button", { type: "button", class: "model-action-btn danger", title: _t("settings.templateDelete") }, _t("settings.templateDelete")); + delBtn.addEventListener("click", () => { + showConfirm(_t("settings.templateDelete"), _t("settings.templateDeleteConfirm", { name: t.name }), { + okText: _t("settings.templateDelete"), + danger: true, + onOk: async () => { + try { + await window.emrg.taskTemplateDelete({ name: t.name }); + Chat.addSystemMessage(_t("settings.templateDeleted")); + await loadTaskMeta(); // 刷新类型选项(任务表单下拉) + await renderTemplateList(); + } catch (e) { + // 决策点②:被任务引用的类型 daemon 拒绝删除(错误信息含任务数) + Chat.addSystemMessage(_t("settings.templateDeleteFailed", { msg: e.message })); + } + }, + }); + }); + actions.appendChild(delBtn); + row.appendChild(actions); + } + wrap.appendChild(row); + } + // 底部"+ 添加类型"(仅自定义类型可增删改) + const addRow = el("div", { style: "display:flex;justify-content:center;margin-top:6px;" }); + const addBtn = el("button", { type: "button", class: "btn btn-ghost", style: "padding:4px 12px;" }, _t("settings.templateAdd")); + addBtn.addEventListener("click", () => openTemplateForm(null)); + addRow.appendChild(addBtn); + wrap.appendChild(addRow); + } + + function openTemplateForm(tpl = null) { + editingTemplate = tpl ? tpl.name : null; + $("task-template-name").value = tpl ? tpl.name : ""; + $("task-template-name").disabled = Boolean(tpl); // daemon 以 name 定位 → 不可改名 + $("task-template-prompt").value = tpl ? (tpl.prompt || "") : ""; + $("task-template-form").classList.remove("hidden"); + $("task-template-name").focus(); + } + + function closeTemplateForm() { + $("task-template-form").classList.add("hidden"); + editingTemplate = null; + $("task-template-name").disabled = false; + $("task-template-name").value = ""; + $("task-template-prompt").value = ""; + } + + async function saveTemplateForm() { + const name = $("task-template-name").value.trim(); + const prompt = $("task-template-prompt").value; + if (!name || !prompt.trim()) { + showConfirm(_t("dlg.stepTitle"), _t("settings.templateInvalid"), { okText: _t("dlg.gotIt"), danger: false }); + return; + } + try { + if (editingTemplate === null) await window.emrg.taskTemplateCreate({ name, prompt }); + else await window.emrg.taskTemplateUpdate({ name, prompt }); + Chat.addSystemMessage(_t("settings.templateSaved")); + closeTemplateForm(); + await loadTaskMeta(); + await renderTemplateList(); + } catch (e) { + Chat.addSystemMessage(_t("settings.templateSaveFailed", { msg: e.message })); + } + } + + function initTaskManagement() { + const addBtn = $("task-add-btn"); + if (addBtn) addBtn.addEventListener("click", () => openTaskForm(null)); + const saveBtn = $("task-form-save"); + if (saveBtn) saveBtn.addEventListener("click", saveTaskForm); + const cancelBtn = $("task-form-cancel"); + if (cancelBtn) cancelBtn.addEventListener("click", closeTaskForm); + const tmgrBtn = $("task-template-mgr-btn"); + if (tmgrBtn) { + tmgrBtn.addEventListener("click", async () => { + const wrap = $("task-template-list"); + if (!wrap) return; + if (wrap.classList.contains("hidden")) { + wrap.classList.remove("hidden"); + closeTemplateForm(); // 收起表单,避免与列表并列 + await loadTaskMeta(); + await renderTemplateList(); + } else { + wrap.classList.add("hidden"); + } + }); + } + const tSave = $("task-template-save"); + if (tSave) tSave.addEventListener("click", saveTemplateForm); + const tCancel = $("task-template-cancel"); + if (tCancel) tCancel.addEventListener("click", closeTemplateForm); + const nameInput = $("task-form-name"); + if (nameInput) { + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); saveTaskForm(); } + else if (e.key === "Escape") { e.preventDefault(); closeTaskForm(); } + }); + } + const tplInput = $("task-template-name"); + if (tplInput) { + tplInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); saveTemplateForm(); } + else if (e.key === "Escape") { e.preventDefault(); closeTemplateForm(); } + }); + } + } + // ── 确认对话框(替代 confirm/alert) ──── let confirmCb = null; function showConfirm(title, message, opts = {}) { @@ -828,6 +1125,10 @@ const Dialogs = (() => { showOpenSessionDialog, // P5:两步打开会话 initNewSessionDialog, // P5 slice 2:新建会话对话框初始化 showNewSessionDialog, // P5 slice 2:新建会话(选项目) + initTaskManagement, // rant 18:23:15 P3:定时任务/自定义类型管理初始化 + renderTaskList, // rant 18:23:15 P3:任务列表渲染(测试/刷新复用) + saveTaskForm, // rant 18:23:15 P3:任务表单提交(测试复用) + saveTemplateForm, // rant 18:23:15 P3:类型表单提交(测试复用) showRename, submitRename, showSettings, diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 1caf0ccd..a789dcc4 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -75,6 +75,40 @@ const I18N = (() => { "settings.aboutTitle": "关于", "settings.aboutDesc": "EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。", "settings.recentTitle": "最近改进", + "settings.groupTasks": "定时任务", + "settings.tasksHint": "管理定时任务与自定义任务类型(内置类型只读)", + "settings.taskAdd": "+ 添加任务", + "settings.taskTemplates": "自定义类型", + "settings.taskName": "任务名", + "settings.taskType": "类型", + "settings.taskProject": "项目", + "settings.taskInterval": "间隔(秒,≥60)", + "settings.taskEnabled": "启用", + "settings.taskRepo": "仓库(选填 owner/repo)", + "settings.taskSave": "保存任务", + "settings.taskTrigger": "触发", + "settings.taskEdit": "编辑", + "settings.taskDelete": "删除", + "settings.taskEmpty": "暂无任务", + "settings.taskBuiltin": "内置", + "settings.taskCustom": "自定义", + "settings.taskDeleteConfirm": "删除任务 {name}?", + "settings.templateName": "类型名", + "settings.templatePrompt": "提示词模板", + "settings.templateSave": "保存类型", + "settings.templateEdit": "编辑", + "settings.templateDelete": "删除", + "settings.templateEmpty": "暂无自定义类型(内置类型只读)", + "settings.templateDeleteConfirm": "删除自定义类型 {name}?", + "settings.taskSaved": "任务已保存", + "settings.taskDeleted": "任务已删除", + "settings.taskIntervalInvalid": "间隔必须 ≥ 60 秒", + "settings.templateSaved": "类型已保存", + "settings.templateDeleted": "类型已删除", + "settings.templateAdd": "+ 添加类型", + "settings.templateInvalid": "类型名与提示词模板不能为空", + "settings.templateSaveFailed": "类型保存失败:{msg}", + "settings.templateDeleteFailed": "类型删除失败:{msg}", "settings.saveModel": "保存模型", "settings.defaultHint": "点左侧圆点可设为默认模型,切换后下一条消息即生效", "settings.groupWorkdir": "工作目录", @@ -427,6 +461,40 @@ const I18N = (() => { "settings.aboutTitle": "About", "settings.aboutDesc": "EMRG is a self-evolving AI agent — it reports every improvement, and you can watch it grow right here.", "settings.recentTitle": "Recent improvements", + "settings.groupTasks": "Scheduled tasks", + "settings.tasksHint": "Manage scheduled tasks and custom task types (builtin types are read-only)", + "settings.taskAdd": "+ Add task", + "settings.taskTemplates": "Custom types", + "settings.taskName": "Task name", + "settings.taskType": "Type", + "settings.taskProject": "Project", + "settings.taskInterval": "Interval (sec, ≥60)", + "settings.taskEnabled": "Enabled", + "settings.taskRepo": "Repo (optional owner/repo)", + "settings.taskSave": "Save task", + "settings.taskTrigger": "Trigger", + "settings.taskEdit": "Edit", + "settings.taskDelete": "Delete", + "settings.taskEmpty": "No tasks", + "settings.taskBuiltin": "builtin", + "settings.taskCustom": "custom", + "settings.taskDeleteConfirm": "Delete task {name}?", + "settings.templateName": "Type name", + "settings.templatePrompt": "Prompt template", + "settings.templateSave": "Save type", + "settings.templateEdit": "Edit", + "settings.templateDelete": "Delete", + "settings.templateEmpty": "No custom types (builtin types are read-only)", + "settings.templateDeleteConfirm": "Delete custom type {name}?", + "settings.taskSaved": "Task saved", + "settings.taskDeleted": "Task deleted", + "settings.taskIntervalInvalid": "Interval must be at least 60 seconds", + "settings.templateSaved": "Type saved", + "settings.templateDeleted": "Type deleted", + "settings.templateAdd": "+ Add type", + "settings.templateInvalid": "Type name and prompt template must not be empty", + "settings.templateSaveFailed": "Failed to save type: {msg}", + "settings.templateDeleteFailed": "Failed to delete type: {msg}", "settings.saveModel": "Save model", "settings.defaultHint": "Click the dot on the left to set the default model — takes effect on the next message", "settings.groupWorkdir": "Working directory", diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index b504a12a..4c33341d 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -693,6 +693,58 @@ test("RESPONSE_TYPES 映射表与 daemon 命令名一致(修正 clear/rename/t assert.strictEqual(r10.content, "hi"); }); +test("rant 18:23:15 P2/P3:RESPONSE_TYPES 覆盖任务/模板 CRUD(task_result / templates_list / template_result)", async () => { + const client = new DaemonClient({ projectDir: tmpHome }); + await connectClient(client); + const send = (obj) => currentMockWs.emit("message", Buffer.from(JSON.stringify(obj))); + // task_create → task_result + const p1 = client.sendCommandAndWait("task_create", { name: "t1", type: "evolution", project: "emrg", interval: 600 }, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "task_result", ok: true, task: { name: "t1" }, summary: { added: ["t1"] } }); + const r1 = await p1; + assert.strictEqual(r1.type, "task_result"); + assert.strictEqual(r1.ok, true); + assert.deepStrictEqual(r1.summary.added, ["t1"]); + // task_update → task_result + const p2 = client.sendCommandAndWait("task_update", { name: "t1", interval: 1200 }, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "task_result", ok: true, task: { name: "t1", interval: 1200 } }); + const r2 = await p2; + assert.strictEqual(r2.type, "task_result"); + assert.strictEqual(r2.task.interval, 1200); + // task_delete → task_result + const p3 = client.sendCommandAndWait("task_delete", { name: "t1" }, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "task_result", ok: true, summary: { removed: ["t1"] } }); + const r3 = await p3; + assert.strictEqual(r3.type, "task_result"); + assert.deepStrictEqual(r3.summary.removed, ["t1"]); + // task_template_list → templates_list + const p4 = client.sendCommandAndWait("task_template_list", {}, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "templates_list", templates: [{ name: "evolution", builtin: true }, { name: "sync", builtin: false }] }); + const r4 = await p4; + assert.strictEqual(r4.type, "templates_list"); + assert.strictEqual(r4.templates.length, 2); + // task_template_create / update / delete → template_result + const p5 = client.sendCommandAndWait("task_template_create", { name: "sync", prompt: "# s" }, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "template_result", ok: true }); + const r5 = await p5; + assert.strictEqual(r5.type, "template_result"); + assert.strictEqual(r5.ok, true); + const p6 = client.sendCommandAndWait("task_template_update", { name: "sync", prompt: "# s2" }, 2000); + await new Promise((r) => setTimeout(r, 10)); + send({ type: "template_result", ok: true }); + const r6 = await p6; + assert.strictEqual(r6.type, "template_result"); + const p7 = client.sendCommandAndWait("task_template_delete", { name: "sync" }, 2000); + await new Promise((r) => setTimeout(r, 10)); + // G103:带 error 键的帧 reject 未决命令(daemon template_result 错误形态) + send({ type: "template_result", ok: false, error: "builtin task type is read-only" }); + await assert.rejects(p7, /read-only/); +}); + test("命令-响应配对超时 → reject(G93)", async () => { const client = new DaemonClient({ projectDir: tmpHome }); await connectClient(client); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index d5e00f9e..bb14f822 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -131,6 +131,13 @@ const ELEMENT_IDS = [ "growth-card", "growth-count", "about-recent", "about-update", "about-update-check-btn", "github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss", + // rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) + "task-list", "task-add-btn", "task-template-mgr-btn", + "task-form", "task-form-name", "task-form-type", "task-form-project", + "task-form-interval", "task-form-enabled", "task-form-repo", + "task-form-cancel", "task-form-save", + "task-template-form", "task-template-name", "task-template-prompt", + "task-template-cancel", "task-template-save", "task-template-list", ]; /** 构造浏览器沙箱(win 即全局对象) */ @@ -2438,3 +2445,158 @@ test("rant 12:10:downloaded_version == 当前版本 → 无安装按钮(退 assert.ok(!child.className.includes("btn"), "not a button when downloaded == current"); assert.ok((child.attributes.href || "").includes("releases"), "falls back to the Releases link"); }); + +// ── rant 18:23:15 P3:定时任务/自定义类型管理(settings 区) ── +test("P3:设置面板打开 → 任务列表渲染(名称/类型/项目/间隔)+ 编辑预填 → taskUpdate", async () => { + let updated = null; + const { ctx, els } = makeSandbox({ + listTasks: async () => [ + { name: "daily-report", type: "evolution", config: { project: "emrg" }, interval: 1800, enabled: true }, + { name: "nightly-sync", type: "sync", config: { project: "docs", repo: "acme/docs" }, interval: 3600, enabled: false }, + ], + taskTemplateList: async () => [ + { name: "evolution", builtin: true, template: "evolution_prompt.md" }, + { name: "sync", builtin: false, template: "sync.md", prompt: "# sync\nrun the sync" }, + ], + listProjects: async () => [{ name: "emrg", path: "/p/emrg" }, { name: "docs", path: "/p/docs" }], + taskUpdate: async (payload) => { updated = payload; return { ok: true }; }, + triggerTask: async () => ({}), + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showSettings()", ctx); + await tick(); + // 任务行渲染:2 行 + 空态兜底(无) + const rows = vm.runInContext('document.getElementById("task-list").children.length', ctx); + assert.strictEqual(rows, 2, `任务列表应渲染 2 行,实际 ${rows}`); + const names = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => r.querySelector(".task-name") ? r.querySelector(".task-name").textContent : "")`, ctx); + assert.deepStrictEqual(names, ["daily-report", "nightly-sync"]); + const hints = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => r.querySelector(".task-hint") ? r.querySelector(".task-hint").textContent : "")`, ctx); + assert.ok(hints[0].includes("emrg") && hints[0].includes("1800"), `任务1 hint: ${hints[0]}`); + assert.ok(hints[1].includes("docs") && hints[1].includes("3600") && hints[1].includes("已停用"), `任务2 hint: ${hints[1]}`); + // 点击编辑 → 表单预填(名称只读、类型/项目下拉、间隔、enabled) + els["task-form"].classList.add("hidden"); // 镜像 index.html 初始态(task-form hidden) + await vm.runInContext(`(function() { + const row = document.getElementById("task-list").children[0]; + const btns = row.querySelectorAll(".model-action-btn"); + btns[1].click(); // 0=触发 1=编辑 + })()`, ctx); + assert.strictEqual(vm.runInContext('document.getElementById("task-form").classList.contains("hidden")', ctx), false, "编辑应展开表单"); + assert.strictEqual(els["task-form-name"].value, "daily-report"); + assert.strictEqual(els["task-form-name"].disabled, true, "编辑时名称只读"); + assert.strictEqual(els["task-form-interval"].value, 1800); + assert.strictEqual(els["task-form-enabled"].checked, true); + assert.strictEqual(els["task-form-repo"].value, ""); + // 修改后保存 → taskUpdate + vm.runInContext('document.getElementById("task-form-interval").value = "600"', ctx); + await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx); + await tick(); + assert.ok(updated, "taskUpdate 应被调用"); + assert.strictEqual(updated.name, "daily-report"); + assert.strictEqual(updated.interval, 600); + assert.strictEqual(updated.type, "evolution"); + assert.strictEqual(updated.project, "emrg"); + assert.strictEqual(updated.enabled, true); +}); + +test("P3:新增任务表单 —— 间隔 <60 客户端拒绝;≥60 提交 taskCreate", async () => { + let created = null; + const { ctx, els } = makeSandbox({ + listTasks: async () => [], + taskTemplateList: async () => [ + { name: "evolution", builtin: true, template: "evolution_prompt.md" }, + { name: "custom-a", builtin: false, template: "custom-a.md", prompt: "# a" }, + ], + listProjects: async () => [{ name: "emrg", path: "/p/emrg" }], + taskCreate: async (payload) => { created = payload; return { ok: true }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showSettings()", ctx); + await tick(); + // 点"+ 添加任务" → 空表单 + els["task-form"].classList.add("hidden"); // 镜像 index.html 初始态(task-form hidden) + await vm.runInContext('document.getElementById("task-add-btn").click()', ctx); + assert.strictEqual(els["task-form-name"].disabled, false, "新增时名称可编辑"); + // 类型下拉含内置 + 自定义 + const typeOpts = vm.runInContext('Array.from(document.getElementById("task-form-type").children).map((o) => o.value)', ctx); + assert.deepStrictEqual(typeOpts.sort(), ["custom-a", "evolution"]); + const projOpts = vm.runInContext('Array.from(document.getElementById("task-form-project").children).map((o) => o.value)', ctx); + assert.deepStrictEqual(projOpts, ["emrg"], "项目下拉仅已注册项目(决策点③)"); + // 间隔 30 → 拒绝(决策点⑤:≥60) + vm.runInContext(`(function() { + document.getElementById("task-form-name").value = "new-task"; + document.getElementById("task-form-type").value = "custom-a"; + document.getElementById("task-form-project").value = "emrg"; + document.getElementById("task-form-interval").value = "30"; + })()`, ctx); + await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx); + await tick(); + assert.strictEqual(els["confirm-dialog"].open, true, "间隔 <60 应弹错误确认框"); + assert.ok((els["confirm-message"].textContent || "").includes("60"), `错误提示含 60:${els["confirm-message"].textContent}`); + assert.strictEqual(created, null, "非法间隔不应提交 taskCreate"); + await vm.runInContext("EMRG_Dialogs.closeConfirm()", ctx); + // 间隔 600 → 提交(含自定义类型 + 启用勾选) + vm.runInContext('document.getElementById("task-form-interval").value = "600"', ctx); + await vm.runInContext("EMRG_Dialogs.saveTaskForm()", ctx); + await tick(); + assert.ok(created, "合法输入应提交 taskCreate"); + assert.strictEqual(created.name, "new-task"); + assert.strictEqual(created.type, "custom-a"); + assert.strictEqual(created.project, "emrg"); + assert.strictEqual(created.interval, 600); + assert.strictEqual(created.enabled, true); + assert.strictEqual(vm.runInContext('document.getElementById("task-form").classList.contains("hidden")', ctx), true, "保存后表单收起"); +}); + +test("P3:自定义类型管理 —— 内置只读;自定义增删改走模板 CRUD", async () => { + let createdTpl = null; + let deletedTpl = null; + const { ctx, els } = makeSandbox({ + listTasks: async () => [], + listProjects: async () => [{ name: "emrg", path: "/p/emrg" }], + taskTemplateList: async () => [ + { name: "evolution", builtin: true, template: "evolution_prompt.md" }, + { name: "sync", builtin: false, template: "sync.md", prompt: "# sync prompt" }, + ], + taskTemplateCreate: async (payload) => { createdTpl = payload; return { ok: true }; }, + taskTemplateDelete: async (payload) => { deletedTpl = payload; return { ok: true }; }, + }); + await tick(); + await vm.runInContext("EMRG_Dialogs.showSettings()", ctx); + await tick(); + // 打开自定义类型列表(镜像 index.html 初始态:task-template-list hidden) + els["task-template-list"].classList.add("hidden"); + await vm.runInContext('document.getElementById("task-template-mgr-btn").click()', ctx); + await tick(); + const wrap = els["task-template-list"]; + assert.strictEqual(wrap.classList.contains("hidden"), false, "类型列表应展开"); + const rows = wrap.children.filter((c) => c.className.includes("task-row")); + assert.strictEqual(rows.length, 2, `内置 + 自定义各一行,实际 ${rows.length}`); + // 内置行无操作按钮(决策点①⑥);自定义行有编辑/删除 + const builtinActions = rows[0].querySelectorAll(".model-action-btn").length; + const customActions = rows[1].querySelectorAll(".model-action-btn").length; + assert.strictEqual(builtinActions, 0, "内置类型只读(无操作按钮)"); + assert.strictEqual(customActions, 2, "自定义类型有编辑+删除"); + // 删除自定义 → 确认 → taskTemplateDelete(决策点②:daemon 拒绝被引用类型) + const delBtn = rows[1].querySelectorAll(".model-action-btn")[1]; + delBtn.click(); + await tick(); + assert.strictEqual(els["confirm-dialog"].open, true, "删除需确认"); + await vm.runInContext("EMRG_Dialogs.confirmOk()", ctx); + await tick(); + assert.ok(deletedTpl, "taskTemplateDelete 应被调用"); + assert.strictEqual(deletedTpl.name, "sync"); + // 新增自定义类型(底部"+ 添加类型")→ 表单 → taskTemplateCreate + const addBtn = wrap.children[wrap.children.length - 1]; + addBtn.click(); + await tick(); + assert.strictEqual(els["task-template-name"].disabled, false, "新增类型名称可编辑"); + vm.runInContext(`(function() { + document.getElementById("task-template-name").value = "nightly"; + document.getElementById("task-template-prompt").value = "# nightly\\nrun at night"; + })()`, ctx); + await vm.runInContext("EMRG_Dialogs.saveTemplateForm()", ctx); + await tick(); + assert.ok(createdTpl, "taskTemplateCreate 应被调用"); + assert.strictEqual(createdTpl.name, "nightly"); + assert.ok(createdTpl.prompt.includes("nightly"), "prompt 原样提交"); +});