Răsfoiți Sursa

feat: save workspace snapshots from sidebar

zhenyu.hu 3 săptămâni în urmă
părinte
comite
0a5c14f71e

+ 27 - 0
docs/plans/todo-26-workspace-snapshot-save.md

@@ -0,0 +1,27 @@
+# Todo 26: Workspace Snapshot Save
+
+## Status
+
+done
+
+## Goal
+
+Move save/load/delete controls out of the ChatAgent config modal and make their meaning explicit: a saved item is a workspace snapshot, not only a prompt set.
+
+## Root Cause
+
+The existing save feature still works, but it is hidden inside the ChatAgent config dialog and named as a prompt set. The saved payload already includes prompt items, pre-messages, ChatAgent config, EventAgent config, selected tools, and event loop settings, so the UI label and placement no longer match the actual behavior.
+
+## Scope
+
+- Add a visible Workspace Snapshot section in the left panel.
+- Remove the save/load/delete controls from the ChatAgent config modal.
+- Rename browser-side storage helpers from prompt-set wording to workspace-snapshot wording.
+- Keep backward compatibility for existing `agent-lab.prompt-sets.v1` localStorage data.
+- Preserve the request payload behavior used by chat runs.
+
+## Verification
+
+- Static tests prove snapshot controls are outside the ChatAgent dialog and save the same workspace fields used by `buildRequest()`.
+- `uv run pytest tests/test_websocket_api.py -q` passes: 27 tests.
+- `uv run pytest`

+ 1 - 0
docs/plans/todos.md

@@ -52,3 +52,4 @@
 | 23 | done | `docs/plans/todo-23-agent-prompt-and-event-llm.md` | Move Prompt Config into ChatAgent config, show available-event prompt by default, and make EventAgent use LLM tool calls to generate tool arguments. | `uv run pytest` passes (`51 passed`, one existing Starlette deprecation warning); changed Python modules compile; local server returned page/static assets/tools API. |
 | 24 | done | `docs/plans/todo-24-chat-agent-prompt-list.md` | Put all ChatAgent prompts, including generated EventAgent event rules, into one ordered prompt list with add/delete/drag controls. | `uv run pytest` passes (`51 passed`, one existing Starlette deprecation warning), plus local browser prompt-list check. |
 | 25 | done | `docs/plans/todo-25-prompt-list-message-types.md` | Make the ChatAgent prompt list visually compact and show the message type for each prompt item. | `uv run pytest` passes (`51 passed`, one existing Starlette deprecation warning); local service returned `/health` and versioned JS asset. |
+| 26 | done | `docs/plans/todo-26-workspace-snapshot-save.md` | Move save/load/delete out of ChatAgent config and save one workspace snapshot covering prompts, Agent config, and selected tools. | `uv run pytest tests/test_websocket_api.py -q` passes (`27 passed`, one existing Starlette deprecation warning). |

+ 82 - 57
src/agent_lab/presentation/static/app.js

@@ -3,8 +3,8 @@ const statusEl = document.querySelector("#connection-status");
 const chatForm = document.querySelector("#chat-form");
 const userMessage = document.querySelector("#user-message");
 const runButton = document.querySelector("#run-button");
-const promptSetName = document.querySelector("#prompt-set-name");
-const savedPromptSets = document.querySelector("#saved-prompt-sets");
+const snapshotName = document.querySelector("#workspace-snapshot-name");
+const savedSnapshots = document.querySelector("#saved-workspace-snapshots");
 const systemPrompts = document.querySelector("#system-prompts");
 const preMessages = document.querySelector("#pre-messages");
 const preMessageTemplate = document.querySelector("#pre-message-template");
@@ -12,7 +12,8 @@ const toolList = document.querySelector("#tool-list");
 const toolCount = document.querySelector("#tool-count");
 const chatConfigDialog = document.querySelector("#chat-config-dialog");
 const eventConfigDialog = document.querySelector("#event-config-dialog");
-const PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
+const WORKSPACE_SNAPSHOTS_STORAGE_KEY = "agent-lab.workspace-snapshots.v1";
+const LEGACY_PROMPT_SETS_STORAGE_KEY = "agent-lab.prompt-sets.v1";
 
 let socket = null;
 let activeAssistant = null;
@@ -31,9 +32,9 @@ bindClick("#add-pre-message", () => {
   preMessages.append(createPreMessage({ role: "user", content: "" }));
 });
 
-bindClick("#save-prompt-set", savePromptSet);
-bindClick("#load-prompt-set", loadPromptSet);
-bindClick("#delete-prompt-set", deletePromptSet);
+bindClick("#save-workspace-snapshot", saveWorkspaceSnapshot);
+bindClick("#load-workspace-snapshot", loadWorkspaceSnapshot);
+bindClick("#delete-workspace-snapshot", deleteWorkspaceSnapshot);
 bindClick("#open-chat-config-panel", openChatConfig);
 bindClick("#close-chat-config", () => {
   chatConfigDialog.close();
@@ -55,7 +56,7 @@ chatForm.addEventListener("submit", (event) => {
 });
 
 initializePromptList();
-refreshPromptSetSelector();
+refreshWorkspaceSnapshotSelector();
 loadTools();
 
 function openChatConfig() {
@@ -156,57 +157,57 @@ function updateToolCount() {
   toolCount.textContent = `${selected}/${total} selected`;
 }
 
-function savePromptSet() {
-  const name = promptSetName.value.trim();
+function saveWorkspaceSnapshot() {
+  const name = snapshotName.value.trim();
   if (!name) {
-    statusEl.textContent = "Set name required";
+    statusEl.textContent = "Snapshot name required";
     return;
   }
 
-  const store = readPromptSets();
-  store.sets[name] = buildPromptSet();
-  writePromptSets(store);
-  refreshPromptSetSelector(name);
-  statusEl.textContent = "Prompt set saved";
+  const store = readWorkspaceSnapshots();
+  store.snapshots[name] = buildWorkspaceSnapshot();
+  writeWorkspaceSnapshots(store);
+  refreshWorkspaceSnapshotSelector(name);
+  statusEl.textContent = "Workspace snapshot saved";
 }
 
-function loadPromptSet() {
-  const name = savedPromptSets.value;
+function loadWorkspaceSnapshot() {
+  const name = savedSnapshots.value;
   if (!name) {
-    statusEl.textContent = "Select a prompt set";
+    statusEl.textContent = "Select a snapshot";
     return;
   }
 
-  const promptSet = readPromptSets().sets[name];
-  if (!promptSet) {
-    statusEl.textContent = "Prompt set not found";
-    refreshPromptSetSelector();
+  const snapshot = readWorkspaceSnapshots().snapshots[name];
+  if (!snapshot) {
+    statusEl.textContent = "Snapshot not found";
+    refreshWorkspaceSnapshotSelector();
     return;
   }
 
-  promptSetName.value = name;
-  restorePromptSet(promptSet);
-  statusEl.textContent = "Prompt set loaded";
+  snapshotName.value = name;
+  restoreWorkspaceSnapshot(snapshot);
+  statusEl.textContent = "Workspace snapshot loaded";
 }
 
-function deletePromptSet() {
-  const name = savedPromptSets.value;
+function deleteWorkspaceSnapshot() {
+  const name = savedSnapshots.value;
   if (!name) {
-    statusEl.textContent = "Select a prompt set";
+    statusEl.textContent = "Select a snapshot";
     return;
   }
 
-  const store = readPromptSets();
-  delete store.sets[name];
-  writePromptSets(store);
-  if (promptSetName.value.trim() === name) {
-    promptSetName.value = "";
+  const store = readWorkspaceSnapshots();
+  delete store.snapshots[name];
+  writeWorkspaceSnapshots(store);
+  if (snapshotName.value.trim() === name) {
+    snapshotName.value = "";
   }
-  refreshPromptSetSelector();
-  statusEl.textContent = "Prompt set deleted";
+  refreshWorkspaceSnapshotSelector();
+  statusEl.textContent = "Workspace snapshot deleted";
 }
 
-function buildPromptSet() {
+function buildWorkspaceSnapshot() {
   return {
     prompt_items: buildPromptItems(),
     system_prompts: buildCustomPromptItems()
@@ -237,17 +238,17 @@ function buildPromptSet() {
   };
 }
 
-function restorePromptSet(promptSet) {
-  restoreSystemPrompts(promptSet.prompt_items || promptSet.system_prompts || []);
-  restorePreMessages(promptSet.pre_messages || []);
+function restoreWorkspaceSnapshot(snapshot) {
+  restoreSystemPrompts(snapshot.prompt_items || snapshot.system_prompts || []);
+  restorePreMessages(snapshot.pre_messages || []);
 
-  const chatAgent = promptSet.chat_agent || {};
+  const chatAgent = snapshot.chat_agent || {};
   document.querySelector("#model").value = chatAgent.model || "";
   document.querySelector("#temperature").value = chatAgent.temperature ?? 0.2;
   document.querySelector("#max-tokens").value = chatAgent.max_tokens ?? 800;
   restoreExtraBody("chat", chatAgent.extra_body);
 
-  const eventAgent = promptSet.event_agent || {};
+  const eventAgent = snapshot.event_agent || {};
   document.querySelector("#event-model").value = eventAgent.model || "";
   document.querySelector("#event-temperature").value = eventAgent.temperature ?? 0.2;
   document.querySelector("#event-max-tokens").value = eventAgent.max_tokens ?? 800;
@@ -529,33 +530,57 @@ function bindPromptDrag(item) {
   });
 }
 
-function refreshPromptSetSelector(selectedName = "") {
-  const previousName = selectedName || savedPromptSets.value;
-  const names = Object.keys(readPromptSets().sets).sort();
-  savedPromptSets.textContent = "";
+function refreshWorkspaceSnapshotSelector(selectedName = "") {
+  const previousName = selectedName || savedSnapshots.value;
+  const names = Object.keys(readWorkspaceSnapshots().snapshots).sort();
+  savedSnapshots.textContent = "";
 
   const placeholder = document.createElement("option");
   placeholder.value = "";
-  placeholder.textContent = names.length ? "Select a set" : "No saved sets";
-  savedPromptSets.append(placeholder);
+  placeholder.textContent = names.length ? "Select a snapshot" : "No saved snapshots";
+  savedSnapshots.append(placeholder);
 
   names.forEach((name) => {
     const option = document.createElement("option");
     option.value = name;
     option.textContent = name;
-    savedPromptSets.append(option);
+    savedSnapshots.append(option);
   });
 
   if (names.includes(previousName)) {
-    savedPromptSets.value = previousName;
+    savedSnapshots.value = previousName;
   }
 }
 
-function readPromptSets() {
+function readWorkspaceSnapshots() {
   try {
-    const rawValue = localStorage.getItem(PROMPT_SETS_STORAGE_KEY);
+    const rawValue = localStorage.getItem(WORKSPACE_SNAPSHOTS_STORAGE_KEY);
     if (!rawValue) {
-      return { sets: {} };
+      return readLegacyPromptSets();
+    }
+
+    const parsed = JSON.parse(rawValue);
+    if (
+      !parsed ||
+      typeof parsed !== "object" ||
+      !parsed.snapshots ||
+      typeof parsed.snapshots !== "object" ||
+      Array.isArray(parsed.snapshots)
+    ) {
+      return { snapshots: {} };
+    }
+
+    return parsed;
+  } catch {
+    return { snapshots: {} };
+  }
+}
+
+function readLegacyPromptSets() {
+  try {
+    const rawValue = localStorage.getItem(LEGACY_PROMPT_SETS_STORAGE_KEY);
+    if (!rawValue) {
+      return { snapshots: {} };
     }
 
     const parsed = JSON.parse(rawValue);
@@ -566,17 +591,17 @@ function readPromptSets() {
       typeof parsed.sets !== "object" ||
       Array.isArray(parsed.sets)
     ) {
-      return { sets: {} };
+      return { snapshots: {} };
     }
 
-    return parsed;
+    return { snapshots: parsed.sets };
   } catch {
-    return { sets: {} };
+    return { snapshots: {} };
   }
 }
 
-function writePromptSets(store) {
-  localStorage.setItem(PROMPT_SETS_STORAGE_KEY, JSON.stringify(store));
+function writeWorkspaceSnapshots(store) {
+  localStorage.setItem(WORKSPACE_SNAPSHOTS_STORAGE_KEY, JSON.stringify(store));
 }
 
 function runDebugSession() {

+ 15 - 14
src/agent_lab/presentation/static/index.html

@@ -35,6 +35,20 @@
           </div>
         </section>
 
+        <section class="snapshot-section">
+          <h2>Workspace Snapshot</h2>
+          <label>Name <input id="workspace-snapshot-name" placeholder="debug workspace" /></label>
+          <label>
+            Saved Snapshot
+            <select id="saved-workspace-snapshots"></select>
+          </label>
+          <div class="button-row">
+            <button id="save-workspace-snapshot" type="button">Save</button>
+            <button id="load-workspace-snapshot" type="button">Load</button>
+            <button id="delete-workspace-snapshot" type="button">Delete</button>
+          </div>
+        </section>
+
       </aside>
 
       <section class="workspace">
@@ -71,19 +85,6 @@
             <label class="checkbox"><input id="chat-forced-search" type="checkbox" /> Forced search</label>
           </fieldset>
         </section>
-        <section>
-          <label>Name <input id="prompt-set-name" placeholder="default debugger" /></label>
-          <label>
-            Saved Set
-            <select id="saved-prompt-sets"></select>
-          </label>
-          <div class="button-row">
-            <button id="save-prompt-set" type="button">Save</button>
-            <button id="load-prompt-set" type="button">Load</button>
-            <button id="delete-prompt-set" type="button">Delete</button>
-          </div>
-        </section>
-
         <section>
           <div class="section-head">
             <h2>Prompt List</h2>
@@ -163,6 +164,6 @@
       </div>
     </template>
 
-    <script src="/static/app.js?v=20260706-prompt-types"></script>
+    <script src="/static/app.js?v=20260706-workspace-snapshot"></script>
   </body>
 </html>

+ 20 - 11
tests/test_websocket_api.py

@@ -457,20 +457,23 @@ def test_static_tools_ui_is_dynamic_and_not_hardcoded_to_handoff_note_checkbox()
     assert "fetch" in js
 
 
-def test_static_prompt_workspace_controls_are_available_in_chat_agent_config():
+def test_static_workspace_snapshot_controls_are_available_outside_agent_config():
     html = Path("src/agent_lab/presentation/static/index.html").read_text()
     chat_dialog_start = html.index('<dialog id="chat-config-dialog"')
     event_dialog_start = html.index('<dialog id="event-config-dialog"')
     chat_dialog_html = html[chat_dialog_start:event_dialog_start]
+    sidebar_html = html[:chat_dialog_start]
 
     assert 'id="open-prompt-config"' not in html
     assert '<dialog id="prompt-dialog"' not in html
-    assert 'id="prompt-set-name"' in html
-    assert 'id="saved-prompt-sets"' in html
-    assert 'id="save-prompt-set"' in html
-    assert 'id="load-prompt-set"' in html
-    assert 'id="delete-prompt-set"' in html
-    assert 'id="prompt-set-name"' in chat_dialog_html
+    assert 'id="workspace-snapshot-name"' in sidebar_html
+    assert 'id="saved-workspace-snapshots"' in sidebar_html
+    assert 'id="save-workspace-snapshot"' in sidebar_html
+    assert 'id="load-workspace-snapshot"' in sidebar_html
+    assert 'id="delete-workspace-snapshot"' in sidebar_html
+    assert 'id="workspace-snapshot-name"' not in chat_dialog_html
+    assert 'id="saved-workspace-snapshots"' not in chat_dialog_html
+    assert 'id="save-workspace-snapshot"' not in chat_dialog_html
     assert 'id="system-prompts"' in chat_dialog_html
     assert 'id="pre-messages"' in chat_dialog_html
 
@@ -600,13 +603,19 @@ def test_static_app_shows_wait_state_and_immediate_user_echo():
     assert ".user" in css
 
 
-def test_static_prompt_workspace_uses_stable_storage_hooks():
+def test_static_workspace_snapshot_uses_stable_storage_hooks():
     js = Path("src/agent_lab/presentation/static/app.js").read_text()
 
+    assert "agent-lab.workspace-snapshots.v1" in js
     assert "agent-lab.prompt-sets.v1" in js
-    assert "function savePromptSet()" in js
-    assert "function loadPromptSet()" in js
-    assert "function deletePromptSet()" in js
+    assert "function saveWorkspaceSnapshot()" in js
+    assert "function loadWorkspaceSnapshot()" in js
+    assert "function deleteWorkspaceSnapshot()" in js
+    assert "function buildWorkspaceSnapshot()" in js
+    assert "prompt_items: buildPromptItems()" in js
+    assert "enabled_tools: selectedTools()" in js
+    assert "extra_body: buildExtraBody(\"chat\")" in js
+    assert "extra_body: buildExtraBody(\"event\")" in js
 
 
 def test_static_app_handles_backend_round_stats_messages():