diff --git a/datalab/aiassistant/controller.py b/datalab/aiassistant/controller.py index 02b8fc057..e2e33ddb0 100644 --- a/datalab/aiassistant/controller.py +++ b/datalab/aiassistant/controller.py @@ -93,6 +93,12 @@ " link so the user can opt in to persisting it. Always pick a short,\n" " descriptive 'title' (it is shown in that link and used as the saved\n" " macro's name).\n" + "- When the user asks you to write, create or give a macro (or a script),\n" + " call 'create_and_run_macro' with 'autorun' set to false: the code is\n" + " validated and offered through the 'Save to Macros' link, without being\n" + " executed. Set 'autorun' to true (or omit it) only if the user also asks\n" + " to run it. NEVER paste the code of a macro in your reply instead of\n" + " calling the tool.\n" "- 'create_and_run_macro' returns the macro console output (stdout + stderr,\n" " including Python tracebacks) and exit code. If exit_code != 0, READ the\n" " output, fix the macro, and call the tool again.\n" diff --git a/datalab/aiassistant/tools/builtin.py b/datalab/aiassistant/tools/builtin.py index fe6f73977..3929746f0 100644 --- a/datalab/aiassistant/tools/builtin.py +++ b/datalab/aiassistant/tools/builtin.py @@ -589,6 +589,10 @@ def _tool_create_and_run_macro( } if not autorun: result["autorun"] = False + result["note"] = ( + "Macro not executed. The user can save it to the Macro panel " + "with the 'Save to Macros' link." + ) return result console = macropanel.console @@ -1062,9 +1066,12 @@ def build_default_registry(expose_macro_tool: bool = True) -> ToolRegistry: "the Macro panel; the user can opt in via a " "'Save to Macros' link surfaced on the result, so always " "pick a short, descriptive title (e.g. 'Generate " - "super-Gaussian'). Use this for complex multi-step " - "scripts; prefer 'apply_operation' for atomic actions. " - "If 'ok' is False, read 'console_output' or " + "super-Gaussian'). Set 'autorun' to false when the user " + "asks you to write (not run) a macro: the code is then " + "only validated and offered through the 'Save to Macros' " + "link, without being executed. Use this for complex " + "multi-step scripts; prefer 'apply_operation' for atomic " + "actions. If 'ok' is False, read 'console_output' or " "'validation_errors' and try again." ), parameters={ @@ -1075,7 +1082,16 @@ def build_default_registry(expose_macro_tool: bool = True) -> ToolRegistry: "type": "string", "description": "Full Python source code of the macro.", }, - "autorun": {"type": "boolean", "default": True}, + "autorun": { + "type": "boolean", + "default": True, + "description": ( + "Run the macro (default true). Set to false " + "to only validate the code and offer it to " + "the user for saving to the Macro panel, " + "without executing it." + ), + }, "timeout": { "type": "number", "default": 30.0, diff --git a/datalab/aiassistant/widgets/chatpanel.py b/datalab/aiassistant/widgets/chatpanel.py index e395084d2..1e4162db3 100644 --- a/datalab/aiassistant/widgets/chatpanel.py +++ b/datalab/aiassistant/widgets/chatpanel.py @@ -7,9 +7,11 @@ from __future__ import annotations import html +import json import os.path as osp +import textwrap import uuid -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from guidata.configtools import get_icon from guidata.widgets.dockable import DockableWidgetMixin @@ -27,7 +29,10 @@ from datalab.aiassistant.providers import get_provider from datalab.aiassistant.providers.base import TokenUsage from datalab.aiassistant.tools.builtin import build_default_registry -from datalab.aiassistant.widgets.markdown import markdown_to_html +from datalab.aiassistant.widgets.markdown import ( + is_python_code_block, + markdown_to_html, +) from datalab.aiassistant.widgets.toolconfirmdialog import ToolConfirmDialog from datalab.aiassistant.worker import AIWorker from datalab.config import Conf, _, get_config_path @@ -45,6 +50,10 @@ _INPUT_HISTORY_FILENAME = "input_history.txt" _INPUT_HISTORY_MAX = 500 _CONVERSATIONS_MAX = 200 +_MACRO_TOOL_NAME = "create_and_run_macro" +_DEFAULT_MACRO_TITLE = "AI Assistant macro" +_SAVE_MACRO_LINK = "save-macro" +_SHOW_MACRO_PANEL_LINK = "show-macro-panel" class _GuiBridge(QC.QObject): @@ -118,11 +127,15 @@ def __init__( max_size=_INPUT_HISTORY_MAX, ) self._current_conversation: Conversation | None = None - # Transient macros generated by the LLM via 'create_and_run_macro'. - # They are NOT added to the Macro panel by default; the user can - # save them on demand by clicking the 'Save to Macros' link - # surfaced after each macro tool call. Keyed by short uuid. + # Macros proposed by the LLM, either through 'create_and_run_macro' + # or as Python code blocks in its replies. They are NOT added to the + # Macro panel by default; the user can save them on demand by + # clicking the 'Save to Macros' link surfaced next to them. Keyed by + # short uuid. self._pending_macros: dict[str, tuple[str, str]] = {} + # Last user request, used to name the macros saved from the code + # blocks of the reply. + self._last_user_text: str | None = None self._setup_ui() # ------------------------------------------------------------------ UI @@ -274,10 +287,30 @@ def _append_user(self, text: str) -> None: def _append_assistant(self, text: str) -> None: if not text: return + footer = ( + self._code_block_save_link if Conf.ai_expose_macro_tool.get(True) else None + ) self._append_html( f"
" + f"{self._save_macro_link_html(title, textwrap.dedent(code))}
" ) def _append_tool(self, name: str, ok: bool, summary: str) -> None: @@ -288,14 +321,36 @@ def _append_tool(self, name: str, ok: bool, summary: str) -> None: f"{symbol} {html.escape(name)}: {html.escape(summary)}" ) + def _append_tool_result( + self, name: str, ok: bool, data: Any, error: str | None + ) -> None: + """Append the outcome of a tool call (live or reloaded). + + For a macro proposed through 'create_and_run_macro', a "Save to + Macros" link is added so the user can opt in to persisting it in the + Macro panel (mirrors the DataLab-Web behaviour). + """ + if ok: + summary = str(data)[:200] if data is not None else "ok" + else: + summary = error or "error" + self._append_tool(name, ok, summary) + if name == _MACRO_TOOL_NAME and isinstance(data, dict): + macro_payload = data.get("_macro") + if isinstance(macro_payload, dict): + title = str(macro_payload.get("title") or _DEFAULT_MACRO_TITLE) + code = str(macro_payload.get("code") or "") + if code: + self._append_save_macro_link(title, code) + def _append_system(self, text: str) -> None: self._append_html( f""
- f"{code}"
+ f"{code}{footer}"
)
return f"\x01{len(code_blocks) - 1}\x01"
diff --git a/datalab/aiassistant/widgets/toolconfirmdialog.py b/datalab/aiassistant/widgets/toolconfirmdialog.py
index 30d177768..5db847e74 100644
--- a/datalab/aiassistant/widgets/toolconfirmdialog.py
+++ b/datalab/aiassistant/widgets/toolconfirmdialog.py
@@ -39,10 +39,26 @@ def __init__(
layout = QW.QVBoxLayout(self)
+ no_exec = tool.name == "create_and_run_macro" and (
+ arguments.get("autorun") is False
+ )
+
header = QW.QLabel(_("The AI assistant requests to run the following action:"))
header.setWordWrap(True)
layout.addWidget(header)
+ if no_exec:
+ note = QW.QLabel(
+ ""
+ + _(
+ "This macro will not be executed: approving only offers it "
+ "for saving to the Macro panel."
+ )
+ + ""
+ )
+ note.setWordWrap(True)
+ layout.addWidget(note)
+
title = QW.QLabel(f"{tool.name}")
layout.addWidget(title)
@@ -70,7 +86,9 @@ def __init__(
button_box = QW.QDialogButtonBox(
QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel
)
- button_box.button(QW.QDialogButtonBox.Ok).setText(_("Run"))
+ button_box.button(QW.QDialogButtonBox.Ok).setText(
+ _("Approve") if no_exec else _("Run")
+ )
button_box.button(QW.QDialogButtonBox.Cancel).setText(_("Cancel"))
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po
index 5ca885e5d..26abbedd7 100644
--- a/datalab/locale/fr/LC_MESSAGES/datalab.po
+++ b/datalab/locale/fr/LC_MESSAGES/datalab.po
@@ -148,6 +148,9 @@ msgstr "Échec de l'enregistrement de la macro : %s"
msgid "Saved macro '%s' to the Macro panel."
msgstr "Macro '%s' enregistrée dans le panneau de macros."
+msgid "Show in Macro panel"
+msgstr "Afficher dans le panneau de macros"
+
msgid "Thinking…"
msgstr "Réflexion en cours…"
@@ -356,12 +359,18 @@ msgstr "Assistant IA — Confirmer l'appel d'outil"
msgid "The AI assistant requests to run the following action:"
msgstr "L'assistant IA demande à exécuter l'action suivante :"
+msgid "This macro will not be executed: approving only offers it for saving to the Macro panel."
+msgstr "Cette macro ne sera pas exécutée : l'approuver permet seulement de l'enregistrer dans le panneau de macros."
+
msgid "Parameters:"
msgstr "Paramètres :"
msgid "Macro code:"
msgstr "Code de la macro :"
+msgid "Approve"
+msgstr "Approuver"
+
msgid "Run"
msgstr "Exécuter"
diff --git a/datalab/tests/aiassistant/test_chatpanel_macros.py b/datalab/tests/aiassistant/test_chatpanel_macros.py
new file mode 100644
index 000000000..60d7deb4b
--- /dev/null
+++ b/datalab/tests/aiassistant/test_chatpanel_macros.py
@@ -0,0 +1,212 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""AI Assistant chat panel: macros proposed by the assistant.
+
+Covers the "Save to Macros" links of the chat panel (after a macro tool call,
+in a conversation reloaded from the history and under the Python code blocks
+of a reply), the "Show in Macro panel" link and the macro tool run with
+``autorun=False``.
+"""
+
+from __future__ import annotations
+
+import re
+
+import pytest
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from datalab.aiassistant.controller import TurnResult
+from datalab.aiassistant.conversation import derive_title
+from datalab.aiassistant.providers.base import ChatMessage, ToolCall
+from datalab.aiassistant.tools.builtin import (
+ _tool_create_and_run_macro,
+ build_default_registry,
+)
+from datalab.aiassistant.tools.registry import ToolResult
+from datalab.aiassistant.widgets.toolconfirmdialog import ToolConfirmDialog
+from datalab.config import Conf, _
+from datalab.tests import datalab_test_app_context
+
+MACRO_TITLE = "Generate super-Gaussian"
+MACRO_CODE = "import numpy as np\nprint(np.pi)"
+REQUEST = "Write a macro that generates a super-gaussian curve"
+
+
+@pytest.fixture(autouse=True)
+def _isolated_macro_caches(tmp_path, monkeypatch):
+ """Keep the user's recent-macro and recovery caches untouched."""
+
+ def fake_path(filename: str) -> str:
+ return str(tmp_path / filename)
+
+ for module in ("recentmacros", "macrorecovery"):
+ monkeypatch.setattr(
+ f"datalab.utils.{module}.Configuration.get_path",
+ staticmethod(fake_path),
+ )
+
+
+def _save_ids(panel) -> list[str]:
+ """Ids of the "Save to Macros" links shown in the chat view."""
+ return re.findall(r"datalab:save-macro/(\w+)", panel.history_view.toHtml())
+
+
+def _click(panel, url: str) -> None:
+ panel._on_anchor_clicked(QC.QUrl(url)) # pylint: disable=protected-access
+ QW.QApplication.processEvents()
+
+
+def _is_in_front(dock: QW.QDockWidget) -> bool:
+ """Return True if the dock is the selected tab of its group"""
+ return not dock.visibleRegion().isEmpty()
+
+
+def test_macro_tool_without_autorun_does_not_run() -> None:
+ """``autorun=False`` validates the macro and offers it without running it."""
+ with datalab_test_app_context(console=False) as win:
+ console = win.macropanel.console
+ before = console.toPlainText()
+ titles = win.macropanel.get_macro_titles()
+ result = _tool_create_and_run_macro(
+ None, win, title=MACRO_TITLE, code="print('ran')", autorun=False
+ )
+ QW.QApplication.processEvents()
+ assert result["autorun"] is False
+ assert result["_macro"] == {"title": MACRO_TITLE, "code": "print('ran')"}
+ assert "exit_code" not in result
+ assert "note" in result
+ assert console.toPlainText() == before
+ assert win.macropanel.get_macro_titles() == titles
+
+
+def test_save_links_of_a_reloaded_conversation() -> None:
+ """A reloaded conversation offers "Save to Macros" again, shows the real
+ status of each tool call and lets the user show the saved macro."""
+ messages = [
+ ChatMessage(role="user", content=REQUEST),
+ ChatMessage(
+ role="assistant",
+ tool_calls=[
+ ToolCall("c1", "create_and_run_macro", {"code": MACRO_CODE}),
+ ToolCall("c2", "apply_operation", {"name": "fft"}),
+ ],
+ ),
+ ChatMessage(
+ role="tool",
+ content=ToolResult(
+ ok=True,
+ data={
+ "exit_code": 0,
+ "_macro": {"title": MACRO_TITLE, "code": MACRO_CODE},
+ },
+ ).to_message_content(),
+ tool_call_id="c1",
+ name="create_and_run_macro",
+ ),
+ ChatMessage(
+ role="tool",
+ content=ToolResult(ok=False, error="No selection").to_message_content(),
+ tool_call_id="c2",
+ name="apply_operation",
+ ),
+ ChatMessage(
+ role="assistant",
+ content="Or paste it:\n\n```python\nprint(2)\n```\n\n```bash\nls\n```",
+ ),
+ ]
+ with datalab_test_app_context(console=False) as win:
+ panel = win.aiassistantpanel
+ panel._render_messages(messages) # pylint: disable=protected-access
+ text = panel.history_view.toPlainText()
+ assert "âś“ create_and_run_macro" in text
+ assert "âś— apply_operation: No selection" in text
+
+ # One link for the macro tool result, one for the Python block only
+ ids = _save_ids(panel)
+ assert len(ids) == 2
+
+ win.docks[win.aiassistantpanel].raise_()
+ QW.QApplication.processEvents()
+ assert not _is_in_front(win.docks[win.macropanel])
+
+ _click(panel, f"datalab:save-macro/{ids[0]}")
+ _click(panel, f"datalab:save-macro/{ids[1]}")
+ macro = win.macropanel.get_macro(MACRO_TITLE)
+ assert macro is not None
+ assert macro.get_code().splitlines() == MACRO_CODE.splitlines()
+ block_macro = win.macropanel.get_macro(derive_title(REQUEST))
+ assert block_macro is not None
+ assert block_macro.get_code().splitlines() == ["print(2)"]
+ # Saving does not hide the conversation...
+ assert _is_in_front(win.docks[win.aiassistantpanel])
+ # ...but offers to show the Macro panel
+ assert "datalab:show-macro-panel" in panel.history_view.toHtml()
+ _click(panel, "datalab:show-macro-panel")
+ assert _is_in_front(win.docks[win.macropanel])
+
+
+def test_save_links_of_a_live_turn() -> None:
+ """A live turn offers "Save to Macros" for the macro tool result and for
+ the Python code blocks of the reply, named after the user request."""
+ with datalab_test_app_context(console=False) as win:
+ panel = win.aiassistantpanel
+ panel._last_user_text = REQUEST # pylint: disable=protected-access
+ result = TurnResult(
+ assistant_message="Here it is:\n\n```\nimport numpy as np\n```",
+ tool_executions=[
+ (
+ "create_and_run_macro",
+ {},
+ ToolResult(
+ ok=True,
+ data={
+ "autorun": False,
+ "_macro": {"title": MACRO_TITLE, "code": MACRO_CODE},
+ },
+ ),
+ )
+ ],
+ )
+ panel._on_turn_finished(result) # pylint: disable=protected-access
+ ids = _save_ids(panel)
+ assert len(ids) == 2
+ for macro_id in ids:
+ _click(panel, f"datalab:save-macro/{macro_id}")
+ titles = win.macropanel.get_macro_titles()
+ assert MACRO_TITLE in titles
+ assert derive_title(REQUEST) in titles
+
+
+def test_no_code_block_links_when_macro_tool_disabled() -> None:
+ """Users who disabled AI macros are not offered to save code blocks."""
+ with datalab_test_app_context(console=False) as win:
+ Conf.ai_expose_macro_tool.set(False)
+ panel = win.aiassistantpanel
+ panel._append_assistant("```python\nprint(3)\n```") # pylint: disable=W0212
+ assert _save_ids(panel) == []
+
+
+@pytest.mark.parametrize("autorun", [False, True, None])
+def test_confirm_dialog_tells_when_the_macro_will_not_run(autorun) -> None:
+ """The confirmation dialog states that a macro proposed with
+ ``autorun=False`` will not be executed."""
+ note = _(
+ "This macro will not be executed: approving only offers it "
+ "for saving to the Macro panel."
+ )
+ with datalab_test_app_context(console=False) as win:
+ tool = build_default_registry().get("create_and_run_macro")
+ arguments = {"title": MACRO_TITLE, "code": MACRO_CODE}
+ if autorun is not None:
+ arguments["autorun"] = autorun
+ dialog = ToolConfirmDialog(tool, arguments, win)
+ labels = [label.text() for label in dialog.findChildren(QW.QLabel)]
+ ok_button = dialog.findChild(QW.QDialogButtonBox).button(QW.QDialogButtonBox.Ok)
+ if autorun is False:
+ assert any(note in text for text in labels)
+ assert ok_button.text() == _("Approve")
+ else:
+ assert not any(note in text for text in labels)
+ assert ok_button.text() == _("Run")
+ dialog.deleteLater()
diff --git a/datalab/tests/aiassistant/test_controller.py b/datalab/tests/aiassistant/test_controller.py
index 283a86bd3..d6ebeb148 100644
--- a/datalab/tests/aiassistant/test_controller.py
+++ b/datalab/tests/aiassistant/test_controller.py
@@ -333,6 +333,18 @@ def test_default_system_prompt_unrestricted_when_no_filter() -> None:
assert "create_and_run_macro" in prompt
+def test_default_system_prompt_routes_macro_writing_to_autorun_false() -> None:
+ """A request to *write* a macro must go through the tool (so that the user
+ gets a 'Save to Macros' link), not through code pasted in the reply."""
+ enabled = " ".join(
+ build_default_system_prompt({"list_objects", "create_and_run_macro"}).split()
+ )
+ disabled = build_default_system_prompt({"list_objects"})
+ assert "call 'create_and_run_macro' with 'autorun' set to false" in enabled
+ assert "NEVER paste the code of a macro in your reply" in enabled
+ assert "autorun" not in disabled
+
+
def test_default_system_prompt_forbids_inline_image_payloads() -> None:
"""The prompt must instruct the model not to embed images or base64 blobs
in its prose responses (would bloat tokens and break QTextBrowser rendering)."""
diff --git a/datalab/tests/aiassistant/test_markdown.py b/datalab/tests/aiassistant/test_markdown.py
new file mode 100644
index 000000000..e5a88bddb
--- /dev/null
+++ b/datalab/tests/aiassistant/test_markdown.py
@@ -0,0 +1,53 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Unit tests for the AI assistant Markdown renderer (code block footers)."""
+
+from __future__ import annotations
+
+from datalab.aiassistant.widgets.markdown import (
+ is_python_code_block,
+ markdown_to_html,
+)
+
+
+def test_is_python_code_block() -> None:
+ """Python fences are recognized by their language or their content."""
+ assert is_python_code_block("python", "x = 1")
+ assert is_python_code_block("py", "x = 1")
+ assert is_python_code_block("Python3", "x = 1")
+ assert is_python_code_block("", "import numpy as np")
+ assert is_python_code_block("", "from sigima import params")
+ assert is_python_code_block("", "proxy.calc('fft')")
+ assert not is_python_code_block("", "just some text")
+ assert not is_python_code_block("", "")
+ assert not is_python_code_block("bash", "import numpy")
+ assert not is_python_code_block("json", '{"proxy.x": 1}')
+
+
+def test_code_block_footer_receives_raw_code() -> None:
+ """The footer callback gets the language and the unescaped code, and its
+ HTML is inserted right after the block."""
+ calls: list[tuple[str, str]] = []
+
+ def footer(lang: str, code: str) -> str:
+ calls.append((lang, code))
+ return f""
+
+ text = (
+ "Intro\n\n```python\nif a < b:\n print('x')\n```\n\n```bash\necho hi\n```\n"
+ )
+ out = markdown_to_html(text, code_block_footer=footer)
+ assert calls == [("python", "if a < b:\n print('x')"), ("bash", "echo hi")]
+ # Code stays escaped in the rendered block; footers follow their block.
+ assert "if a < b:" in out
+ assert out.index("") < out.index("echo hi")
+ assert out.endswith("")
+
+
+def test_no_footer_by_default() -> None:
+ """Without callback, the rendering is unchanged."""
+ text = "```python\nprint(1)\n```"
+ assert markdown_to_html(text) == markdown_to_html(
+ text, code_block_footer=lambda _lang, _code: ""
+ )
+ assert markdown_to_html(text).endswith("")
diff --git a/datalab/tests/aiassistant/test_tool_registry.py b/datalab/tests/aiassistant/test_tool_registry.py
index c51bdbe7e..a9c7f689b 100644
--- a/datalab/tests/aiassistant/test_tool_registry.py
+++ b/datalab/tests/aiassistant/test_tool_registry.py
@@ -108,3 +108,14 @@ def test_default_registry_can_hide_macro_tool() -> None:
assert "create_and_run_macro" not in names
# Read-only macro inspection tool stays available (no code execution).
assert "get_macro_console_output" in names
+
+
+def test_macro_tool_documents_autorun() -> None:
+ """The model must be told how to propose a macro without running it."""
+ reg = build_default_registry()
+ tool = reg.get("create_and_run_macro")
+ autorun = tool.parameters["properties"]["autorun"]
+ assert autorun["type"] == "boolean"
+ assert autorun["default"] is True
+ assert "without executing" in autorun["description"]
+ assert "'autorun' to false" in tool.description
diff --git a/doc/features/advanced/ai_assistant.rst b/doc/features/advanced/ai_assistant.rst
index ae57b78fa..1593c36c8 100644
--- a/doc/features/advanced/ai_assistant.rst
+++ b/doc/features/advanced/ai_assistant.rst
@@ -74,6 +74,14 @@ When the assistant proposes a macro, the generated code is **transient**: it is
executed without cluttering the Macro panel. A **Save to Macros** link is offered
after the run if you want to keep it.
+When you ask the assistant to *write* a macro rather than to run it, the code is
+only proposed: the confirmation dialog states that the macro will not be
+executed, and the same **Save to Macros** link is offered. Such a link is also
+offered under each Python code block of the assistant's replies (unless macro
+creation is disabled), and all these links are available again when a
+conversation is reopened from **History...**. Once a macro is saved, a **Show in
+Macro panel** link brings that panel to the front.
+
Tools available to the assistant
--------------------------------
@@ -132,7 +140,7 @@ explicit confirmation.
- Run any registered processing feature
* - ``create_and_run_macro``
- ..
- - Create and execute a Python macro
+ - Create and execute a Python macro, or only propose it for saving
.. note::
diff --git a/doc/locale/fr/LC_MESSAGES/features/advanced/ai_assistant.po b/doc/locale/fr/LC_MESSAGES/features/advanced/ai_assistant.po
index 560024376..651ddcae1 100644
--- a/doc/locale/fr/LC_MESSAGES/features/advanced/ai_assistant.po
+++ b/doc/locale/fr/LC_MESSAGES/features/advanced/ai_assistant.po
@@ -87,6 +87,9 @@ msgstr "La zone de saisie conserve un historique des messages précédemment env
msgid "When the assistant proposes a macro, the generated code is **transient**: it is executed without cluttering the Macro panel. A **Save to Macros** link is offered after the run if you want to keep it."
msgstr "Lorsque l'assistant propose une macro, le code généré est **temporaire** : il est exécuté sans encombrer le panneau de macros. Un lien **Enregistrer dans les macros** est proposé après l'exécution si vous souhaitez la conserver."
+msgid "When you ask the assistant to *write* a macro rather than to run it, the code is only proposed: the confirmation dialog states that the macro will not be executed, and the same **Save to Macros** link is offered. Such a link is also offered under each Python code block of the assistant's replies (unless macro creation is disabled), and all these links are available again when a conversation is reopened from **History...**. Once a macro is saved, a **Show in Macro panel** link brings that panel to the front."
+msgstr "Lorsque vous demandez à l'assistant d'*écrire* une macro plutôt que de l'exécuter, le code est seulement proposé : la boîte de dialogue de confirmation indique que la macro ne sera pas exécutée, et le même lien **Enregistrer dans les macros** est proposé. Un tel lien est également proposé sous chaque bloc de code Python des réponses de l'assistant (sauf si la création de macros est désactivée), et tous ces liens sont de nouveau disponibles lorsqu'une conversation est rouverte depuis **Historique...**. Une fois la macro enregistrée, un lien **Afficher dans le panneau de macros** fait passer ce panneau au premier plan."
+
msgid "Tools available to the assistant"
msgstr "Outils Ă la disposition de l'assistant"
@@ -186,8 +189,8 @@ msgstr "Exécuter n'importe quelle fonctionnalité de traitement enregistrée"
msgid "``create_and_run_macro``"
msgstr ""
-msgid "Create and execute a Python macro"
-msgstr "Créer et exécuter une macro Python"
+msgid "Create and execute a Python macro, or only propose it for saving"
+msgstr "Créer et exécuter une macro Python, ou seulement la proposer à l'enregistrement"
msgid "The ``create_and_run_macro`` tool is only exposed to the model when the **Allow AI to create and run macros** option is enabled. When it is disabled, the assistant cannot even propose arbitrary code execution."
msgstr "L'outil ``create_and_run_macro`` n'est exposé au modèle que si l'option **Autoriser l'IA à créer et exécuter des macros** est activée. Lorsqu'elle est désactivée, l'assistant ne peut même pas proposer l'exécution de code arbitraire."
diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po
index f4efe36d0..8c67e1a26 100644
--- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po
+++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.04.po
@@ -573,6 +573,18 @@ msgstr "Correction du blocage de DataLab lorsqu'il était fermé alors qu'une co
msgid "Fixed images with non-uniform coordinates losing them when transferred through the Web API (`GET` and `PUT /objects/{name}/data`): they were rebuilt with default uniform coordinates. The NPZ data format now includes these coordinates; archives written by earlier versions remain readable. Clients with their own NPZ reader, such as DataLab-Kernel, must support the new entries to take advantage of this"
msgstr "Correction des images à coordonnées non uniformes qui perdaient celles-ci lors d'un transfert par l'API Web (`GET` et `PUT /objects/{name}/data`) : elles étaient reconstruites avec des coordonnées uniformes par défaut. Le format de données NPZ inclut désormais ces coordonnées ; les archives écrites par les versions antérieures restent lisibles. Les clients disposant de leur propre lecteur NPZ, comme DataLab-Kernel, doivent prendre en charge les nouvelles entrées pour en bénéficier"
+msgid "**AI Assistant:**"
+msgstr "**Assistant IA :**"
+
+msgid "Fixed the **Save to Macros** link missing when a conversation is reopened from **History...**: a macro proposed by the assistant in a previous session could no longer be saved. Reopened conversations also showed every tool call as successful, including the failed ones; they now show the actual outcome (fixes [Issue #447](https://github.com/DataLab-Platform/DataLab/issues/447))"
+msgstr "Correction de l'absence du lien **Enregistrer dans les macros** lorsqu'une conversation est rouverte depuis **Historique...** : une macro proposée par l'assistant lors d'une session précédente ne pouvait plus être enregistrée. Les conversations rouvertes affichaient aussi tous les appels d'outils comme réussis, y compris ceux en échec ; elles affichent désormais leur résultat réel (corrige [l'issue #447](https://github.com/DataLab-Platform/DataLab/issues/447))"
+
+msgid "Saving a macro proposed by the assistant now offers a **Show in Macro panel** link: the Macro panel is tabbed with the AI Assistant, so the saved macro stayed hidden behind the conversation"
+msgstr "L'enregistrement d'une macro proposée par l'assistant propose désormais un lien **Afficher dans le panneau de macros** : le panneau de macros étant regroupé en onglets avec l'assistant IA, la macro enregistrée restait cachée derrière la conversation"
+
+msgid "Fixed the assistant answering a request to *write* a macro with the code in its reply, which could not be saved to the Macro panel: it now proposes the macro without running it (the confirmation dialog states that it will not be executed) and offers the **Save to Macros** link. A **Save to Macros** link is also offered under each Python code block of its replies, unless macro creation is disabled"
+msgstr "Correction de l'assistant qui répondait à une demande d'*écriture* de macro en plaçant le code dans sa réponse, d'où il ne pouvait pas être enregistré dans le panneau de macros : il propose désormais la macro sans l'exécuter (la boîte de dialogue de confirmation indique qu'elle ne sera pas exécutée) et offre le lien **Enregistrer dans les macros**. Un lien **Enregistrer dans les macros** est également proposé sous chaque bloc de code Python de ses réponses, sauf si la création de macros est désactivée"
+
msgid "**Security:**"
msgstr "**Sécurité :**"
diff --git a/doc/release_notes/release_1.04.md b/doc/release_notes/release_1.04.md
index 819365e26..af6ff08ea 100644
--- a/doc/release_notes/release_1.04.md
+++ b/doc/release_notes/release_1.04.md
@@ -256,6 +256,12 @@ See the [HDF5 browser guide](../features/common/h5browser.rst) for scientific ob
* Fixed DataLab freezing when closed while a remote command was still running (e.g. when closing the main window with `call_method("close")`)
* Fixed images with non-uniform coordinates losing them when transferred through the Web API (`GET` and `PUT /objects/{name}/data`): they were rebuilt with default uniform coordinates. The NPZ data format now includes these coordinates; archives written by earlier versions remain readable. Clients with their own NPZ reader, such as DataLab-Kernel, must support the new entries to take advantage of this
+**AI Assistant:**
+
+* Fixed the **Save to Macros** link missing when a conversation is reopened from **History...**: a macro proposed by the assistant in a previous session could no longer be saved. Reopened conversations also showed every tool call as successful, including the failed ones; they now show the actual outcome (fixes [Issue #447](https://github.com/DataLab-Platform/DataLab/issues/447))
+* Saving a macro proposed by the assistant now offers a **Show in Macro panel** link: the Macro panel is tabbed with the AI Assistant, so the saved macro stayed hidden behind the conversation
+* Fixed the assistant answering a request to *write* a macro with the code in its reply, which could not be saved to the Macro panel: it now proposes the macro without running it (the confirmation dialog states that it will not be executed) and offers the **Save to Macros** link. A **Save to Macros** link is also offered under each Python code block of its replies, unless macro creation is disabled
+
**Security:**
* Opening a workspace received from someone else can no longer make DataLab load or run other code installed on the computer. The parameters stored with objects, analysis results and History actions name their class, and DataLab imported whatever module they named and called the object named there, which a modified file could use, for example, to make DataLab quit. Only parameter classes of DataLab, Sigima, guidata and installed plugins are now used; other stored parameters are reported as unreadable (fixes [Issue #407](https://github.com/DataLab-Platform/DataLab/issues/407))