diff --git a/README.md b/README.md index 6737ec36cc..d878b4d338 100644 --- a/README.md +++ b/README.md @@ -3187,6 +3187,14 @@ become available in the next batch, when rejected additions can be retried. ### Long-Term Memory +Settings > Appearance > Language also supplies the preferred language for replies +and new automatic DeerMem facts and summary updates on subsequent chat turns and +replays. Explicit user language requests take precedence. Existing memories are +not bulk translated, and other memory backends retain their own language policy. +Model-native reasoning text may still use another language. +Scheduled runs and MCP task notification runs do not inherit this preference; +their replies and memory updates continue to infer language from run messages. + Mem0 search and management reads apply `failure_policy.read` to invalid record scores, categories, and metadata. `fail_open` returns an empty result; `fail_closed` reports a read error. A malformed later record never returns a diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 50d8a42de2..96d7a413cc 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -186,11 +186,18 @@ float filters accept integer or real JSON numbers through `json_value_matches`. Gate server-owned run context on both client feeds (`body.context` and `body.config`): `merge_run_context_overrides` admits it only for `internal=True`, -while `strip_internal_context_keys` scrubs both destinations. Treat +while `strip_internal_context_keys` scrubs both destinations. A validated +`context.locale` (`en-US` / `zh-CN`) is a runtime-only language preference for +replies and memory writes; it never persists into checkpoint `configurable`, +and an explicit invalid/null top-level value clears a legacy preference. Treat `disable_clarification` like `non_interactive`. Before run/state writes, `_normalize_input_messages` rejects canonical external system/developer roles; only `AUTH_SOURCE_INTERNAL` run input may retain them. +`contracts/ui_locales.v1.json` pins the UI/Gateway locale allowlists in tests; +DeerMem's portable map stays separate. Scheduled/MCP notification runs omit +locale; persisted owner locale is used for scheduled IM notices only. + `start_run` assigns IDs to ID-less messages before recording input or invoking the graph; keyed retries keep those IDs stable, while the pre-stamp input remains the idempotency identity. The record and checkpoint share IDs so diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index c783b65384..c2d6002612 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -44,6 +44,7 @@ from app.gateway.utils import sanitize_log_param from app.mcp_tasks.errors import PermanentNotificationError from deerflow.agents.human_input import read_human_input_response from deerflow.agents.interaction_policy import resolve_run_interaction_policy +from deerflow.agents.language import ui_locale from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY, _REMINDER_DATE_KEY from deerflow.agents.middlewares.input_sanitization_middleware import frame_untrusted_text from deerflow.agents.middlewares.message_utils import _SUMMARY_MESSAGE_NAME, is_genuine_user_message @@ -1032,7 +1033,13 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An # ``build_run_config`` copies ``body.config['configurable']`` verbatim, # and ``configurable`` is persisted in checkpoints. existing_configurable.pop("client", None) + existing_configurable.pop("locale", None) existing_runtime_context = config.get("context") + if isinstance(existing_runtime_context, dict) and "locale" in existing_runtime_context: + locale = ui_locale(existing_runtime_context) + existing_runtime_context.pop("locale") + if locale is not None: + existing_runtime_context["locale"] = locale for section in (existing_configurable, existing_runtime_context): if isinstance(section, dict): for key in _MODEL_SELECTION_CONTEXT_KEYS: @@ -1049,6 +1056,13 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An configurable = config.setdefault("configurable", {}) runtime_context = config.setdefault("context", {}) + # UI language is public request data, never a checkpoint default. An + # explicit invalid/null value clears a legacy config.context preference. + if "locale" in context and isinstance(runtime_context, dict): + runtime_context.pop("locale", None) + locale = ui_locale(context) + if locale is not None: + runtime_context["locale"] = locale keys = _CONTEXT_CONFIGURABLE_KEYS | _CONTEXT_INTERNAL_CALLER_KEYS if internal else _CONTEXT_CONFIGURABLE_KEYS for key in keys: if key in context: diff --git a/backend/docs/API.md b/backend/docs/API.md index 8296143a18..4fa93b1033 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -393,6 +393,16 @@ runs have their own floor; see **Client Context:** +Top-level `context.locale` accepts `en-US` or `zh-CN` as the current run's +preferred response and automatic DeerMem-writing language. Explicit user +language requests take precedence. Unsupported values are ignored. The value +is runtime-only and is not a checkpoint `configurable` default; legacy +`config.context.locale` is also validated, with an explicit null/invalid +top-level value clearing it. Omitting a locale retains language inference. +Scheduled runs and MCP task notification runs currently omit this preference; +the owner's saved locale localizes scheduled IM notices only, not those runs' +replies or memory writes. + A custom client can describe what it can display under top-level `context.client`, so the agent and its skills can choose an output shape that fits (for example a CSV download or a chart). diff --git a/backend/packages/harness/deerflow/agents/AGENTS.md b/backend/packages/harness/deerflow/agents/AGENTS.md index 6a08d487df..1662e273f8 100644 --- a/backend/packages/harness/deerflow/agents/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/AGENTS.md @@ -1,5 +1,10 @@ ### Agent System +Web runs may supply `context.locale` (`en-US` / `zh-CN`) as a response-language +preference; explicit user language requests win. DynamicContext projects it as +request-only human data, leaving the static system prompt independent of locale. +Clients without a supported locale retain message-language inference. + `AppConfig.lead_prompt_overlay` wraps the fully rendered lead system prompt with literal operator prepend/append text. Do not template-format these extensions or source them from run context. The graph and assembly descriptor must see the diff --git a/backend/packages/harness/deerflow/agents/language.py b/backend/packages/harness/deerflow/agents/language.py new file mode 100644 index 0000000000..0de9e10bd1 --- /dev/null +++ b/backend/packages/harness/deerflow/agents/language.py @@ -0,0 +1,10 @@ +"""Finite UI-language preferences; arbitrary client text is never rendered.""" + +from collections.abc import Mapping + +UI_LANGUAGES = {"en-US": "English", "zh-CN": "Simplified Chinese"} + + +def ui_locale(context: object) -> str | None: + value = context.get("locale") if isinstance(context, Mapping) else None + return value if isinstance(value, str) and value in UI_LANGUAGES else None diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 7b497bb452..27a83ffafc 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -823,7 +823,8 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f - Call `present_files` for the image before referencing it. - Use "```mermaid" for Mermaid diagrams. - Multi-task: Better utilize parallel tool calling to call multiple tools at one time for better performance -- Language Consistency: Write everything the user reads in the language of the user's latest message: short notes before tool calls, progress updates, +- Language Consistency: An explicit user request for a response language takes precedence. Otherwise, use the preferred language supplied in + , when present; without it, use the language of the user's latest message. Apply this to short notes before tool calls, progress updates, the final answer, and text you store for later such as task titles, scheduled instructions and notes. In a run without a user message, such as a scheduled run, use the language the user wrote the task instructions in; host-added English lines such as the stop rule or notes wrapper do not change it. Keep code, commands, file paths and quoted source text unchanged. diff --git a/backend/packages/harness/deerflow/agents/memory/AGENTS.md b/backend/packages/harness/deerflow/agents/memory/AGENTS.md index 7cc85c7fcb..8da58177d0 100644 --- a/backend/packages/harness/deerflow/agents/memory/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/memory/AGENTS.md @@ -1,5 +1,16 @@ ### Memory System +`locale.py::with_memory_locale` captures a supported run `context.locale` on +queued message copies only, after PII redaction, for normal/async/emergency +writes. Stamp every message so filtering and watermark slicing retain the +preference; strip inbound/stale `deerflow_ui_locale` metadata when no supported +run locale exists. Never change checkpoint messages or memory backend APIs. +Bundled DeerMem reads this optional metadata into its human data message, +keeping system text static and the portable backend free of host imports. +Language influences new prose, not JSON keys or bulk translation of stored facts. +`test_ui_language_preference.py` pins DeerMem's locale keys and display names +to the host allowlist without importing host code into the portable backend. + DeerMem `prompt_prepend`/`prompt_append` wrap the first system message from `memory_update` after formatting, without touching its human data message or cached templates. This is the live summary/fact extraction path; do not wire diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml index 20eb81fc90..37d953bb88 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml @@ -95,6 +95,9 @@ messages: - longTermBackground: Unchanging foundational facts about the user **Multilingual Content**: + - When a preferred memory language is supplied, use it for new facts and updated summary prose unless the user explicitly requests another memory language. + - The preferred language is a current-run UI setting, not a durable fact about the user. Do not store it or translate unchanged facts merely to match it. + - Keep JSON keys, categories, and classification values unchanged. - Preserve original language for proper nouns and company names - Keep technical terms in their original form (DeepSeek, LangGraph, etc.) - Note language capabilities in personalContext diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py index 4ff7d0584e..5e1e2ff45f 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py @@ -886,6 +886,18 @@ def _message_identity(msg: Any) -> tuple[str, ...] | None: return ("content", msg_type, hashlib.sha256(text.encode("utf-8")).hexdigest()) +# DeerMem is portable; the host test pins this map to its UI allowlist. +_MEMORY_UI_LANGUAGES = {"en-US": "English", "zh-CN": "Simplified Chinese"} + + +def _memory_language_preference(messages: list[Any]) -> str: + for message in reversed(messages): + locale = (getattr(message, "additional_kwargs", {}) or {}).get("deerflow_ui_locale") + if isinstance(locale, str) and locale in _MEMORY_UI_LANGUAGES: + return f"Preferred memory language: {_MEMORY_UI_LANGUAGES[locale]} ({locale})" + return "" + + class MemoryUpdater: """Updates memory using LLM based on conversation context.""" @@ -1453,6 +1465,13 @@ class MemoryUpdater: "consolidation_section": consolidation_section, } prompt = load_prompt_messages("memory_update", variables, agent_name=agent_name, prompts_dir=self._prompts_dir) + language_preference = _memory_language_preference(messages) + if language_preference: + # Per-run preference is data, not part of the cached system text. + for index, message in enumerate(prompt): + if message.type == "human": + prompt[index] = message.model_copy(update={"content": f"{language_preference}\n\n{message.content}"}) + break if config.prompt_prepend or config.prompt_append: # Extend trusted instructions only, after formatting the original # template. Conversation/memory data stays in its human message. diff --git a/backend/packages/harness/deerflow/agents/memory/locale.py b/backend/packages/harness/deerflow/agents/memory/locale.py new file mode 100644 index 0000000000..f977047b1e --- /dev/null +++ b/backend/packages/harness/deerflow/agents/memory/locale.py @@ -0,0 +1,18 @@ +"""Capture the run preference without changing checkpoint or backend APIs.""" + +from deerflow.agents.language import ui_locale + +_MEMORY_LOCALE_KEY = "deerflow_ui_locale" + + +def with_memory_locale(messages: list, context: object) -> list: + locale = ui_locale(context) + captured = [] + for message in messages: + kwargs = {key: value for key, value in message.additional_kwargs.items() if key != _MEMORY_LOCALE_KEY} + if locale is not None: + kwargs[_MEMORY_LOCALE_KEY] = locale + # Stamp every queued copy: filtering and the extraction watermark may + # leave only an assistant tail. Never write this marker to graph state. + captured.append(message.model_copy(update={"additional_kwargs": kwargs}) if kwargs != message.additional_kwargs else message) + return captured diff --git a/backend/packages/harness/deerflow/agents/memory/summarization_hook.py b/backend/packages/harness/deerflow/agents/memory/summarization_hook.py index 5b9a93f2e7..e23645b59f 100644 --- a/backend/packages/harness/deerflow/agents/memory/summarization_hook.py +++ b/backend/packages/harness/deerflow/agents/memory/summarization_hook.py @@ -3,6 +3,7 @@ from __future__ import annotations from deerflow.agents.memory import get_memory_manager +from deerflow.agents.memory.locale import with_memory_locale from deerflow.agents.middlewares.memory_middleware import redact_queued_messages from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent from deerflow.config.memory_config import get_memory_config @@ -27,7 +28,7 @@ def memory_flush_hook(event: SummarizationEvent, pii_redaction_config: PiiRedact user_id = resolve_runtime_user_id(event.runtime) get_memory_manager().add_nowait( event.thread_id, - redact_queued_messages(list(event.messages_to_summarize), pii_redaction_config), + with_memory_locale(redact_queued_messages(list(event.messages_to_summarize), pii_redaction_config), getattr(event.runtime, "context", None)), agent_name=event.agent_name, user_id=user_id, ) diff --git a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py index a2055fd244..28c417147c 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/dynamic_context_middleware.py @@ -58,6 +58,7 @@ from langchain.agents.middleware.types import ModelCallResult, ModelRequest, Mod from langchain_core.messages import HumanMessage, RemoveMessage, SystemMessage from langgraph.runtime import Runtime +from deerflow.agents.language import UI_LANGUAGES, ui_locale from deerflow.agents.middlewares.message_utils import insert_after_leading_system_messages from deerflow.agents.middlewares.pii_redaction_middleware import redact_text from deerflow.projects.context import build_project_context_message, is_project_context_message, pinned_project_snapshot, project_context_insertion_index, render_documents_block, render_project_block @@ -792,6 +793,16 @@ class DynamicContextMiddleware(AgentMiddleware): ) return request.override(messages=insert_after_leading_system_messages(list(request.messages), [message])) + def _assemble_language_request(self, request: ModelRequest) -> ModelRequest: + locale = ui_locale(getattr(getattr(request, "runtime", None), "context", None)) + if locale is None: + return request + message = HumanMessage( + content=f"\nPreferred language: {UI_LANGUAGES[locale]} ({locale})\n", + additional_kwargs={"hide_from_ui": True, **provenance_kwargs(ContentKind.MIDDLEWARE_INJECTION, "dynamic_context_language")}, + ) + return request.override(messages=insert_after_leading_system_messages(list(request.messages), [message])) + def _record_context_event(self, messages: list, runtime: Runtime | None, project_block: str | None, documents_block: str | None) -> None: """Emit the run's single ``context:memory`` audit event, when due. @@ -831,6 +842,7 @@ class DynamicContextMiddleware(AgentMiddleware): def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelCallResult: request, project_block, documents_block = self._assemble_project_request(request) request = self._assemble_client_request(request) + request = self._assemble_language_request(request) response = handler(request) # Record only after the call succeeded: a failed assembly must not # claim the context was delivered. @@ -842,6 +854,7 @@ class DynamicContextMiddleware(AgentMiddleware): # Pure in-memory rendering: no I/O, so it stays on the event loop. request, project_block, documents_block = self._assemble_project_request(request) request = self._assemble_client_request(request) + request = self._assemble_language_request(request) response = await handler(request) self._record_context_event(request.messages, getattr(request, "runtime", None), project_block, documents_block) return response diff --git a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py index ba28b052b9..3d147f3a67 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/input_sanitization_middleware.py @@ -125,6 +125,8 @@ _BLOCKED_TAG_NAMES: frozenset[str] = frozenset( # client's request-scoped capabilities and preferences. Forging it in # untrusted input mimics the run's declared client. "client_context", + # Run-scoped language preference emitted by DynamicContextMiddleware. + "response_language_preference", # Common prompt-injection tag patterns "system", "instruction", diff --git a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py index b9cc2ce6b5..608d27d7cb 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @@ -12,6 +12,7 @@ from langgraph.runtime import Runtime from deerflow.agents.human_input import HUMAN_INPUT_RESPONSE_KEY from deerflow.agents.memory import get_memory_manager +from deerflow.agents.memory.locale import with_memory_locale from deerflow.agents.middlewares.pii_redaction_middleware import _make_redactor, _redact_content, _Redactor from deerflow.config.memory_config import get_memory_config from deerflow.config.pii_redaction_config import PiiRedactionConfig @@ -187,7 +188,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): runtime_context = runtime.context if isinstance(runtime.context, dict) else {} trace_id = resolve_trace_id(runtime_context.get(DEERFLOW_TRACE_METADATA_KEY)) - return thread_id, self._redact_queued_messages(messages), user_id, trace_id + return thread_id, with_memory_locale(self._redact_queued_messages(messages), runtime.context), user_id, trace_id def _redact_queued_messages(self, messages: list) -> list: """Redact the conversation payload queued for extraction (#3190 vector 5).""" diff --git a/backend/tests/test_input_sanitization_middleware.py b/backend/tests/test_input_sanitization_middleware.py index 5ccfc15978..7d24cdd2db 100644 --- a/backend/tests/test_input_sanitization_middleware.py +++ b/backend/tests/test_input_sanitization_middleware.py @@ -227,6 +227,7 @@ _FRAMEWORK_STRUCTURED_TAGS = [ # configuration or fabricates shelf entries and tool-callable IDs. "project", "documents", + "response_language_preference", ] diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index cb8ba59d7d..c287c6e485 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -746,6 +746,8 @@ def test_system_prompt_language_rule_covers_scheduled_runs_and_stored_text(): line = " ".join(part.strip() for part in lines[start:end]) assert "the user's latest message" in line + assert "An explicit user request for a response language takes precedence" in line + assert "" in line assert "short notes before tool calls" in line assert "task titles, scheduled instructions and notes" in line assert "scheduled run" in line diff --git a/backend/tests/test_ui_language_preference.py b/backend/tests/test_ui_language_preference.py new file mode 100644 index 0000000000..4f424f9659 --- /dev/null +++ b/backend/tests/test_ui_language_preference.py @@ -0,0 +1,156 @@ +"""The current UI language is request data, including at memory capture.""" + +import asyncio +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, messages_to_dict +from langgraph.runtime import Runtime + +from deerflow.agents.language import UI_LANGUAGES +from deerflow.agents.memory import summarization_hook +from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig +from deerflow.agents.memory.backends.deermem.deermem.core import updater as memory_updater +from deerflow.agents.memory.backends.deermem.deermem.core.message_processing import filter_messages_for_memory +from deerflow.agents.memory.backends.deermem.deermem.core.storage import create_empty_memory +from deerflow.agents.memory.backends.deermem.deermem.core.updater import MemoryUpdater +from deerflow.agents.middlewares import memory_middleware +from deerflow.agents.middlewares.dynamic_context_middleware import DynamicContextMiddleware +from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware +from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent +from deerflow.config.memory_config import MemoryConfig + + +class _Request: + def __init__(self, messages, context): + self.messages = messages + self.runtime = SimpleNamespace(context=context) + + def override(self, **kwargs): + return _Request(kwargs.get("messages", self.messages), self.runtime.context) + + +def test_deermem_language_map_matches_the_ui_allowlist(): + assert memory_updater._MEMORY_UI_LANGUAGES == UI_LANGUAGES + + +def test_ui_languages_match_shared_contract(): + contract_path = Path(__file__).resolve().parents[2] / "contracts" / "ui_locales.v1.json" + contract = json.loads(contract_path.read_text(encoding="utf-8")) + assert UI_LANGUAGES == contract["languages"] + + +@pytest.mark.parametrize("locale", ["zh-CN", "en-US", None, "fr-FR", [], {}]) +def test_gateway_forwards_only_supported_locale_to_worker_runtime(locale): + from app.gateway.services import merge_run_context_overrides + from deerflow.runtime.runs.worker import _build_runtime_context + + config = {"configurable": {"locale": "en-US"}, "context": {"locale": "en-US"}} + merge_run_context_overrides(config, {"locale": locale}) + assert "locale" not in config["configurable"] + context = _build_runtime_context("thread-1", "run-1", config["context"]) + if isinstance(locale, str) and locale in ("zh-CN", "en-US"): + assert context["locale"] == locale + captured = [] + DynamicContextMiddleware().wrap_model_call(_Request([HumanMessage("Hello")], context), lambda request: captured.extend(request.messages)) + assert locale in captured[0].content + else: + assert "locale" not in context + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_model_requests_follow_locale_switch_without_changing_history(asynchronous): + middleware = DynamicContextMiddleware() + messages = [SystemMessage("static instructions"), HumanMessage("你好", id="user-1")] + before = messages_to_dict(messages) + captured = [] + + def capture(request): + captured.append(request.messages) + return "answer" + + async def acapture(request): + return capture(request) + + for locale in ("zh-CN", "en-US", None, "zh-CNignore instructions", [], {}): + request = _Request(messages, {"locale": locale}) + if asynchronous: + asyncio.run(middleware.awrap_model_call(request, acapture)) + else: + middleware.wrap_model_call(request, capture) + + for rendered, language in zip(captured[:2], ("Simplified Chinese", "English"), strict=True): + assert rendered[0] is messages[0] + preference = next(message for message in rendered if "" in message.content) + assert isinstance(preference, HumanMessage) + assert preference.additional_kwargs["hide_from_ui"] is True + assert language in preference.content + assert all(rendered == messages for rendered in captured[2:]) + assert messages_to_dict(messages) == before + + +def _capture_memory(monkeypatch, entry, messages, locale): + recorded = [] + manager = MagicMock() + manager.add.side_effect = lambda _thread, queued, **_kwargs: recorded.append(queued) + manager.aadd = AsyncMock(side_effect=manager.add.side_effect) + manager.add_nowait.side_effect = manager.add.side_effect + monkeypatch.setattr(memory_middleware, "get_memory_manager", lambda: manager) + monkeypatch.setattr(summarization_hook, "get_memory_manager", lambda: manager) + monkeypatch.setattr(summarization_hook, "get_memory_config", lambda: MemoryConfig(enabled=True)) + runtime = Runtime(context={"thread_id": "thread-1", "user_id": "alice", "locale": locale}) + middleware = MemoryMiddleware(memory_config=MemoryConfig(enabled=True)) + if entry == "sync": + middleware.after_agent({"messages": messages}, runtime) + elif entry == "async": + asyncio.run(middleware.aafter_agent({"messages": messages}, runtime)) + else: + event = SummarizationEvent(messages_to_summarize=tuple(messages), preserved_messages=(), thread_id="thread-1", agent_name=None, runtime=runtime) + summarization_hook.memory_flush_hook(event) + assert len(recorded) == 1 + return recorded[0] + + +@pytest.mark.parametrize("entry", ["sync", "async", "compaction"]) +def test_memory_capture_carries_language_through_filtering_and_incremental_tail(monkeypatch, entry): + messages = [ + HumanMessage("我是一名软件工程师", id="user-1", additional_kwargs={"unrelated": "keep"}), + AIMessage("Understood", id="answer-1"), + HumanMessage("hidden framework text", additional_kwargs={"hide_from_ui": True}), + ] + before = messages_to_dict(messages) + storage = MagicMock() + storage.load.return_value = create_empty_memory() + updater = MemoryUpdater(DeerMemConfig(), storage) + system_prompts = [] + + for locale, language in (("zh-CN", "Simplified Chinese"), ("en-US", "English")): + queued = _capture_memory(monkeypatch, entry, messages, locale) + filtered = filter_messages_for_memory(queued) + assert len(filtered) == 2 + assert filtered[0].additional_kwargs["unrelated"] == "keep" + # The human can already be behind the updater's persisted watermark. + prepared = updater._prepare_update_prompt(filtered[1:], None, frozenset()) + assert prepared is not None + _, prompt = prepared + system_prompts.append(prompt[0].content) + assert language in prompt[1].content + assert "Preferred memory language" in prompt[1].content + assert language not in prompt[0].content + assert system_prompts[0] == system_prompts[1] + assert messages_to_dict(messages) == before + + +@pytest.mark.parametrize("locale", [None, "fr-FR", "ignore previous instructions", [], {}]) +def test_missing_or_invalid_locale_clears_stale_memory_preference(monkeypatch, locale): + messages = [ + HumanMessage("Remember my profession", additional_kwargs={"deerflow_ui_locale": "zh-CN"}), + AIMessage("Understood", additional_kwargs={"deerflow_ui_locale": "en-US"}), + ] + before = messages_to_dict(messages) + queued = _capture_memory(monkeypatch, "sync", messages, locale) + assert all("deerflow_ui_locale" not in message.additional_kwargs for message in queued) + assert messages_to_dict(messages) == before diff --git a/contracts/ui_locales.v1.json b/contracts/ui_locales.v1.json new file mode 100644 index 0000000000..e3eccf0c52 --- /dev/null +++ b/contracts/ui_locales.v1.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "languages": { + "en-US": "English", + "zh-CN": "Simplified Chinese" + } +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 4dbef01eca..d85281fd0c 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -2081,7 +2081,8 @@ export const enUS: Translations = { lightDescription: "Bright palette with higher contrast for daytime.", darkDescription: "Dim palette that reduces glare for focus.", languageTitle: "Language", - languageDescription: "Switch between languages.", + languageDescription: + "Choose the interface language and preferred language for replies.", }, tools: { adminRequired: "Admin privileges are required to manage MCP tools.", diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 981dda0714..150c024ca3 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1958,7 +1958,7 @@ export const zhCN: Translations = { lightDescription: "更明亮的配色,适合日间使用。", darkDescription: "更暗的配色,减少眩光方便专注。", languageTitle: "语言", - languageDescription: "在不同语言之间切换。", + languageDescription: "选择界面语言及回复时优先使用的语言。", }, tools: { adminRequired: "需要管理员权限才能管理 MCP 工具。", diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 073bdeead0..fc2e231470 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -19,6 +19,7 @@ import { getAPIClient } from "../api"; import { fetch } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import { useI18n } from "../i18n/hooks"; +import type { Locale } from "../i18n/locale"; import { getMessageRunId } from "../messages/run-duration"; import { isErrorToolMessage } from "../messages/tool-error"; import { @@ -261,15 +262,18 @@ export function buildThreadSubmitMessages({ * attached them, and never from local settings. A stray key in settings is * dropped rather than forwarded, so a stale value can never grant access. * `client_timezone` carries the browser's zone (scheduled-task default only). + * `locale` carries the live UI language and overrides any saved value. */ export function buildRunContext({ settings, threadId, + locale, extraContext, conversationReferences, }: { settings: LocalSettings["context"]; threadId: string; + locale?: Locale; extraContext?: Record; conversationReferences?: string[]; }): Record { @@ -300,6 +304,7 @@ export function buildRunContext({ ? "low" : undefined), thread_id: threadId, + ...(locale ? { locale } : {}), ...clientTimezoneContext(), }; } @@ -1958,7 +1963,7 @@ export function useThreadStream({ onStart, onFinish, }: ThreadStreamOptions) { - const { t } = useI18n(); + const { t, locale } = useI18n(); const currentViewThreadId = displayThreadId ?? threadId ?? null; const currentViewThreadIdRef = useRef(currentViewThreadId); currentViewThreadIdRef.current = currentViewThreadId; @@ -2795,6 +2800,7 @@ export function useThreadStream({ context: buildRunContext({ settings: context, threadId, + locale, extraContext, conversationReferences: options?.conversationReferences, }), @@ -2818,6 +2824,7 @@ export function useThreadStream({ [ thread, t.uploads.uploadingFiles, + locale, context, queryClient, humanMessageCount, @@ -2934,7 +2941,7 @@ export function useThreadStream({ // Replaying a turn never carries conversation references: the grant // is per send, so a regenerate or edit runs without them unless the // user attaches them again. - context: buildRunContext({ settings: context, threadId }), + context: buildRunContext({ settings: context, threadId, locale }), }); void queryClient.invalidateQueries({ queryKey: ["thread", threadId] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); @@ -2971,6 +2978,7 @@ export function useThreadStream({ }, [ context, + locale, humanMessageCount, persistedMessages, queryClient, diff --git a/frontend/tests/e2e/chat-language-preference.spec.ts b/frontend/tests/e2e/chat-language-preference.spec.ts new file mode 100644 index 0000000000..7efe1baf32 --- /dev/null +++ b/frontend/tests/e2e/chat-language-preference.spec.ts @@ -0,0 +1,101 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + handleRunStream, + MOCK_THREAD_ID, + mockLangGraphAPI, +} from "./utils/mock-api"; + +async function switchLanguage( + page: Page, + language: "English" | "中文", + screenshotPath?: string, +) { + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: /Settings and more|设置和更多/ }) + .click(); + await page.getByRole("menuitem", { name: /^(Settings|设置)$/ }).click(); + const dialog = page.getByRole("dialog", { name: /^(Settings|设置)$/ }); + await dialog.getByRole("button", { name: /^(Appearance|外观)$/ }).click(); + await dialog.getByRole("combobox").click(); + await page.getByRole("option", { name: language, exact: true }).click(); + await expect(dialog.getByRole("combobox")).toHaveText(language); + if (screenshotPath) await page.screenshot({ path: screenshotPath }); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); +} + +test("sends the current language after switching in Appearance settings", async ({ + page, +}, testInfo) => { + mockLangGraphAPI(page); + const locales: unknown[] = []; + await page.route("**/runs/stream", (route) => { + const body = route.request().postDataJSON() as { + context?: { locale?: unknown }; + }; + locales.push(body.context?.locale); + return handleRunStream(route); + }); + await page.goto("/workspace/chats/new"); + await switchLanguage( + page, + "中文", + testInfo.outputPath("language-settings.png"), + ); + await page.locator("textarea").fill("Explain this in English"); + await page.locator("textarea").press("Enter"); + await expect.poll(() => locales).toEqual(["zh-CN"]); + await expect( + page.getByText("Hello from DeerFlow!", { exact: true }), + ).toBeVisible(); + + await switchLanguage(page, "English"); + await page.locator("textarea").fill("继续"); + await page.locator("textarea").press("Enter"); + await expect.poll(() => locales).toEqual(["zh-CN", "en-US"]); +}); + +test("regeneration uses the new UI language rather than the original turn", async ({ + page, +}) => { + const human = { type: "human", id: "user-1", content: "Original question" }; + const answer = { type: "ai", id: "answer-1", content: "Original answer" }; + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Language preference", + messages: [human, answer], + }, + ], + }); + await page.route("**/runs/regenerate/prepare", (route) => + route.fulfill({ + json: { + input: { messages: [human] }, + checkpoint: { + checkpoint_id: "before-user", + checkpoint_ns: "", + checkpoint_map: null, + }, + metadata: {}, + target_run_id: `run-${MOCK_THREAD_ID}`, + }, + }), + ); + let locale: unknown; + await page.route("**/runs/stream", (route) => { + locale = ( + route.request().postDataJSON() as { context?: { locale?: unknown } } + ).context?.locale; + return handleRunStream(route); + }); + await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`); + await expect(page.getByText(answer.content, { exact: true })).toBeVisible(); + await switchLanguage(page, "中文"); + await page.locator("[data-assistant-turn]").last().hover(); + await page.getByRole("button", { name: "重新生成", exact: true }).click(); + await expect.poll(() => locale).toBe("zh-CN"); +}); diff --git a/frontend/tests/unit/core/i18n/locale-contract.test.ts b/frontend/tests/unit/core/i18n/locale-contract.test.ts new file mode 100644 index 0000000000..c39c43107a --- /dev/null +++ b/frontend/tests/unit/core/i18n/locale-contract.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "@rstest/core"; + +import { SUPPORTED_LOCALES } from "@/core/i18n/locale"; + +describe("UI locale contract", () => { + it("supports the same locales as the Gateway and DeerMem", () => { + const contract = JSON.parse( + readFileSync( + resolve(__dirname, "../../../../../contracts/ui_locales.v1.json"), + "utf-8", + ), + ) as { languages: Record }; + + expect([...SUPPORTED_LOCALES].sort()).toEqual( + Object.keys(contract.languages).sort(), + ); + }); +}); diff --git a/frontend/tests/unit/core/threads/run-context.test.ts b/frontend/tests/unit/core/threads/run-context.test.ts index 508f5b9fdd..c94387a353 100644 --- a/frontend/tests/unit/core/threads/run-context.test.ts +++ b/frontend/tests/unit/core/threads/run-context.test.ts @@ -10,6 +10,22 @@ const settings = { } as unknown as LocalSettings["context"]; describe("buildRunContext", () => { + it("uses the current UI locale instead of stale saved or caller context", () => { + const stale = { ...settings, locale: "en-US" }; + const context = buildRunContext({ + settings: stale, + threadId: "t-1", + locale: "zh-CN", + extraContext: { locale: "en-US" }, + }); + expect(context.locale).toBe("zh-CN"); + expect(stale.locale).toBe("en-US"); + expect( + buildRunContext({ settings: stale, threadId: "t-1", locale: "en-US" }) + .locale, + ).toBe("en-US"); + }); + it("applies a one-turn model without mutating saved context", () => { const before = { ...settings }; const current = buildRunContext({