diff --git a/deeptutor/agents/chat/agentic_pipeline.py b/deeptutor/agents/chat/agentic_pipeline.py index dff3b0a4a8..0971f69199 100644 --- a/deeptutor/agents/chat/agentic_pipeline.py +++ b/deeptutor/agents/chat/agentic_pipeline.py @@ -58,6 +58,7 @@ ) from deeptutor.services.llm.context_window import resolve_effective_context_window from deeptutor.services.prompt import get_prompt_manager +from deeptutor.services.prompt.language import normalize_language from deeptutor.tools.builtin import PARTNER_BUILTIN_TOOL_NAMES logger = logging.getLogger(__name__) @@ -195,7 +196,7 @@ def __init__( temperature: float | None = None, max_tokens: int | None = None, ) -> None: - self.language = "zh" if language.lower().startswith("zh") else "en" + self.language = normalize_language(language) self.llm_config = get_llm_config() self.binding = getattr(self.llm_config, "binding", None) or "openai" self.model = getattr(self.llm_config, "model", None) diff --git a/deeptutor/agents/chat/chat_agent.py b/deeptutor/agents/chat/chat_agent.py index f532c9443e..2b5445f52d 100644 --- a/deeptutor/agents/chat/chat_agent.py +++ b/deeptutor/agents/chat/chat_agent.py @@ -43,7 +43,7 @@ def __init__( Initialize ChatAgent. Args: - language: Language setting ('zh' | 'en') + language: Language setting (for example ``"en"``, ``"zh"``, or ``"es"``) config: Optional configuration dictionary max_history_tokens: Maximum tokens for conversation history **kwargs: Additional arguments passed to BaseAgent diff --git a/deeptutor/agents/chat/prompt_blocks.py b/deeptutor/agents/chat/prompt_blocks.py index 73b02daf80..368e303aa7 100644 --- a/deeptutor/agents/chat/prompt_blocks.py +++ b/deeptutor/agents/chat/prompt_blocks.py @@ -7,7 +7,7 @@ from deeptutor.capabilities.protocol import PromptBlock from deeptutor.core.context import UnifiedContext -from deeptutor.services.prompt.language import append_language_directive +from deeptutor.services.prompt.language import append_language_directive, normalize_language class ChatPromptAssembler: @@ -15,7 +15,7 @@ class ChatPromptAssembler: def __init__(self, *, prompts: dict[str, Any], language: str) -> None: self.prompts = prompts - self.language = "zh" if language.lower().startswith("zh") else "en" + self.language = normalize_language(language) def system_prompt( self, diff --git a/deeptutor/agents/notebook/analysis_agent.py b/deeptutor/agents/notebook/analysis_agent.py index 032c45b1ea..188da42502 100644 --- a/deeptutor/agents/notebook/analysis_agent.py +++ b/deeptutor/agents/notebook/analysis_agent.py @@ -9,6 +9,7 @@ from deeptutor.core.trace import build_trace_metadata, derive_trace_metadata, new_call_id from deeptutor.services.llm import clean_thinking_tags, get_llm_config, get_token_limit_kwargs from deeptutor.services.llm import stream as llm_stream +from deeptutor.services.prompt.language import append_language_directive, normalize_language from deeptutor.services.prompt.manager import get_prompt_manager from deeptutor.utils.json_parser import parse_json_response @@ -28,7 +29,7 @@ class NotebookAnalysisAgent: """Analyze selected notebook records before the main capability runs.""" def __init__(self, language: str = "en") -> None: - self.language = "zh" if str(language or "en").lower().startswith("zh") else "en" + self.language = normalize_language(language) self.llm_config = get_llm_config() self.model = getattr(self.llm_config, "model", None) self.api_key = getattr(self.llm_config, "api_key", None) @@ -296,13 +297,13 @@ def _stage_text(self, stage: str, field: str) -> str: return str(section.get(field, "")).strip() def _thinking_system_prompt(self) -> str: - return self._stage_text("thinking", "system") + return append_language_directive(self._stage_text("thinking", "system"), self.language) def _acting_system_prompt(self) -> str: - return self._stage_text("acting", "system") + return append_language_directive(self._stage_text("acting", "system"), self.language) def _observing_system_prompt(self) -> str: - return self._stage_text("observing", "system") + return append_language_directive(self._stage_text("observing", "system"), self.language) def _thinking_prompt(self, user_question: str, records: list[dict[str, Any]]) -> str: return self._stage_text("thinking", "user_template").format( diff --git a/deeptutor/agents/notebook/summarize_agent.py b/deeptutor/agents/notebook/summarize_agent.py index e6d7e87433..0df6e16e64 100644 --- a/deeptutor/agents/notebook/summarize_agent.py +++ b/deeptutor/agents/notebook/summarize_agent.py @@ -6,6 +6,7 @@ from deeptutor.services.llm import clean_thinking_tags, get_llm_config, get_token_limit_kwargs from deeptutor.services.llm import stream as llm_stream +from deeptutor.services.prompt.language import append_language_directive, normalize_language from deeptutor.services.prompt.manager import get_prompt_manager @@ -20,7 +21,7 @@ class NotebookSummarizeAgent: """Generate concise summaries for notebook records.""" def __init__(self, language: str = "en") -> None: - self.language = "zh" if str(language or "en").lower().startswith("zh") else "en" + self.language = normalize_language(language) self.llm_config = get_llm_config() self.model = getattr(self.llm_config, "model", None) self.api_key = getattr(self.llm_config, "api_key", None) @@ -93,7 +94,9 @@ async def stream_summary( yield chunk def _system_prompt(self) -> str: - return str(self._prompts.get("system", "")).strip() + return append_language_directive( + str(self._prompts.get("system", "")).strip(), self.language + ) def _build_user_prompt( self, diff --git a/deeptutor/api/routers/chat.py b/deeptutor/api/routers/chat.py index eaf3fc740f..cd5ae4e554 100644 --- a/deeptutor/api/routers/chat.py +++ b/deeptutor/api/routers/chat.py @@ -13,6 +13,7 @@ from deeptutor.agents.chat import ChatAgent, SessionManager from deeptutor.core.context import UnifiedContext from deeptutor.core.stream import StreamEventType +from deeptutor.i18n.languages import normalize_supported_language from deeptutor.runtime.orchestrator import ChatOrchestrator from deeptutor.services.config import PROJECT_ROOT, load_config_with_main from deeptutor.services.llm.config import get_llm_config @@ -108,12 +109,9 @@ async def websocket_chat(websocket: WebSocket): while True: data = await websocket.receive_json() requested_language = str(data.get("language") or "").lower().strip() - language = ( - "zh" - if requested_language.startswith("zh") - else "en" - if requested_language.startswith("en") - else get_response_language(default=config.get("system", {}).get("language", "en")) + language = normalize_supported_language( + requested_language + or get_response_language(default=config.get("system", {}).get("language", "en")) ) message = data.get("message", "").strip() session_id = data.get("session_id") diff --git a/deeptutor/api/routers/quiz_judge.py b/deeptutor/api/routers/quiz_judge.py index eb89553bbd..c183fe596e 100644 --- a/deeptutor/api/routers/quiz_judge.py +++ b/deeptutor/api/routers/quiz_judge.py @@ -14,6 +14,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from deeptutor.i18n.languages import normalize_supported_language from deeptutor.services.config import PROJECT_ROOT, load_config_with_main from deeptutor.services.llm import stream as llm_stream from deeptutor.services.settings.interface_settings import get_response_language @@ -47,6 +48,17 @@ "- Speak directly to the learner's submission — do not give a generic lecture.\n" "- Reply in English." ), + "es": ( + "Eres un asistente docente riguroso y alentador que corrige la respuesta de un estudiante. " + "Usa la pregunta, la respuesta de referencia y la explicación para ofrecer una evaluación concreta.\n\n" + "Requisitos:\n" + "- Empieza con una línea que indique el veredicto: ✅ Correcta / ⚠️ Parcialmente correcta / ❌ Incorrecta, " + "junto con el motivo principal.\n" + "- Enumera después qué está bien, qué es incorrecto o falta y cómo corregirlo.\n" + "- Si existen varias respuestas razonables, reconoce los aciertos del estudiante.\n" + "- Habla directamente sobre la respuesta entregada; evita explicaciones genéricas.\n" + "- Responde siempre en español de España." + ), } @@ -95,6 +107,27 @@ def _build_judge_user_prompt( ) parts.append(f"{count_text},请结合图片中的文字/公式/草图一并判定。") parts.append("请针对该学习者的具体作答给出 AI 评判。") + elif language == "es": + parts = [ + f"Tipo de pregunta: {question_type or 'desconocido'}", + f"Pregunta:\n{question}", + ] + if options_block: + parts.append(f"Opciones:\n{options_block}") + if correct_answer: + parts.append(f"Respuesta de referencia:\n{correct_answer}") + if explanation: + parts.append(f"Explicación de referencia:\n{explanation}") + parts.append( + "Respuesta del estudiante:\n" + + ( + user_answer.strip() + if user_answer and user_answer.strip() + else "(Solo se han enviado imágenes, sin respuesta escrita)" + ) + ) + if has_image: + parts.append(f"Imágenes adjuntas: {image_count}") else: parts = [ f"Question type: {question_type or 'unknown'}", @@ -218,7 +251,7 @@ async def websocket_quiz_judge(websocket: WebSocket): ] | null, "user_answer_image": str | null, # legacy single-image form "image_filename": str | null, # legacy filename for the above - "language": "zh" | "en", + "language": "en" | "zh" | "es", } Server → Client (streaming): @@ -275,12 +308,11 @@ async def safe_send(payload: dict[str, Any]) -> bool: return requested_language = (data.get("language") or "").strip().lower() - if requested_language not in ("zh", "en"): + if requested_language not in ("zh", "en", "es"): requested_language = get_response_language( default=_config.get("system", {}).get("language", "en") ) - if requested_language not in ("zh", "en"): - requested_language = "en" + requested_language = normalize_supported_language(requested_language) user_answer = data.get("user_answer") or "" diff --git a/deeptutor/api/routers/settings.py b/deeptutor/api/routers/settings.py index 8fc8910d4b..7191756a6e 100644 --- a/deeptutor/api/routers/settings.py +++ b/deeptutor/api/routers/settings.py @@ -121,8 +121,8 @@ class SidebarNavOrder(BaseModel): class UISettings(BaseModel): theme: Literal["light", "dark", "glass", "snow"] = "snow" - language: Literal["zh", "en"] = "en" - response_language: Literal["zh", "en"] = "en" + language: Literal["en", "zh", "es"] = "en" + response_language: Literal["en", "zh", "es"] = "en" sidebar_description: Optional[str] = None sidebar_nav_order: Optional[SidebarNavOrder] = None code_block_theme: Optional[str] = None @@ -144,8 +144,8 @@ class UISettingsUpdate(BaseModel): # for exclude_unset partial merges, but an explicit value is still validated # so PUT /ui cannot persist a theme/language the app can't render. theme: Literal["light", "dark", "glass", "snow"] | None = None - language: Literal["zh", "en"] | None = None - response_language: Literal["zh", "en"] | None = None + language: Literal["en", "zh", "es"] | None = None + response_language: Literal["en", "zh", "es"] | None = None sidebar_description: str | None = None sidebar_nav_order: SidebarNavOrder | None = None code_block_theme: str | None = None @@ -166,7 +166,7 @@ class ThemeUpdate(BaseModel): class LanguageUpdate(BaseModel): - language: Literal["zh", "en"] + language: Literal["en", "zh", "es"] class SidebarDescriptionUpdate(BaseModel): diff --git a/deeptutor/api/routers/tools.py b/deeptutor/api/routers/tools.py index 87041bd469..72dd5d9160 100644 --- a/deeptutor/api/routers/tools.py +++ b/deeptutor/api/routers/tools.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging -from typing import Any, Literal +from typing import Any from fastapi import APIRouter from pydantic import BaseModel @@ -61,9 +61,9 @@ class ToolHintsPayload(BaseModel): class BuiltinToolPayload(BaseModel): name: str description: str - description_i18n: dict[Literal["en", "zh"], str] = {} + description_i18n: dict[str, str] = {} parameters: list[ToolParameterPayload] - hints: dict[Literal["en", "zh"], ToolHintsPayload] + hints: dict[str, ToolHintsPayload] aliases: list[str] = [] # True iff the user is allowed to switch this tool on/off from the # /settings/tools UI. Locked-on tools (auto-mounted by the chat diff --git a/deeptutor/api/utils/tool_options.py b/deeptutor/api/utils/tool_options.py index 2f7536e4c0..f15c4eb02f 100644 --- a/deeptutor/api/utils/tool_options.py +++ b/deeptutor/api/utils/tool_options.py @@ -112,6 +112,7 @@ def _describe(name: str) -> dict[str, Any]: "description_i18n": { "en": definition.description or "", "zh": definition.description or "", + "es": definition.description or "", }, } ) diff --git a/deeptutor/capabilities/explore_context/explorer.py b/deeptutor/capabilities/explore_context/explorer.py index 1e430d82bb..577be93bd7 100644 --- a/deeptutor/capabilities/explore_context/explorer.py +++ b/deeptutor/capabilities/explore_context/explorer.py @@ -44,6 +44,7 @@ from deeptutor.services.llm import clean_thinking_tags, get_llm_config, get_token_limit_kwargs from deeptutor.services.llm import stream as llm_stream from deeptutor.services.llm.capabilities import threads_session_id +from deeptutor.services.prompt.language import append_language_directive, normalize_language logger = logging.getLogger(__name__) @@ -89,7 +90,7 @@ class ContextExplorer: """Investigate the turn's attached sources and return an objective briefing.""" def __init__(self, *, language: str, prompts: dict[str, Any]) -> None: - self.language = "zh" if str(language or "en").lower().startswith("zh") else "en" + self.language = normalize_language(language) self._prompts = prompts or {} cfg = get_llm_config() self.model = getattr(cfg, "model", None) @@ -156,7 +157,7 @@ async def _run_loop( source_index: dict[str, str], usage: Any | None, ) -> str: - system_prompt = self._t("loop.system") + system_prompt = append_language_directive(self._t("loop.system"), self.language) user_template = self._t("loop.user_template") if not system_prompt or not user_template: logger.warning("explore_context loop prompts missing; using single pass") @@ -381,7 +382,7 @@ async def _single_pass( sources_text = self._render_source_blocks(source_index) if not sources_text: return "" - system_prompt = self._t("system") + system_prompt = append_language_directive(self._t("system"), self.language) user_template = self._t("user_template") if not system_prompt or not user_template: logger.warning("explore_context single-pass prompts missing; skipping pre-pass") diff --git a/deeptutor/core/context.py b/deeptutor/core/context.py index 5c2f9a3500..c9845a9b32 100644 --- a/deeptutor/core/context.py +++ b/deeptutor/core/context.py @@ -51,7 +51,7 @@ class UnifiedContext: knowledge_bases: KB names to use for RAG. attachments: Images / files sent with the message. config_overrides: Per-request config tweaks (e.g. temperature). - language: UI / response language ("en" | "zh"). + language: UI / response language (for example ``"en"``, ``"zh"``, or ``"es"``). memory_context: Memory snapshot text injected into the system prompt. persona_context: Selected persona's instructions, eagerly injected into the system prompt (a persona must shape the voice from the diff --git a/deeptutor/core/i18n.py b/deeptutor/core/i18n.py index b5ebf4591e..b2d5649c5a 100644 --- a/deeptutor/core/i18n.py +++ b/deeptutor/core/i18n.py @@ -4,12 +4,11 @@ from typing import Any +from deeptutor.i18n.languages import normalize_supported_language + def _parse_language(language: str | None) -> str: - raw = (language or "en").strip().lower() - if raw.startswith("zh") or raw in {"cn", "chinese"}: - return "zh" - return "en" + return normalize_supported_language(language) _MESSAGES: dict[str, dict[str, str]] = { @@ -107,6 +106,62 @@ def _parse_language(language: str | None) -> str: "sandbox.disabled_for_account": "你的账号已禁用代码执行。", "sandbox.no_backend": "没有可用的沙箱后端", }, + "es": { + "api.content_required": "el contenido es obligatorio", + "api.invalid_channels_config": "La configuración de canales no es válida", + "api.partner_already_exists": "El compañero '{name}' ya existe", + "api.partner_not_found": "No se ha encontrado el compañero", + "api.partner_not_found_or_not_running": "No se ha encontrado el compañero o no está en ejecución", + "api.partner_not_running": "El compañero no está en ejecución", + "api.partner_stopped_start_required": "El compañero está detenido. Inícialo antes de chatear.", + "api.persona_already_exists": "La persona ya existe: {name}", + "api.persona_name_required": "El nombre de la persona es obligatorio", + "api.persona_not_found": "No se ha encontrado la persona: {name}", + "api.soul_already_exists": "El perfil '{name}' ya existe", + "api.soul_content_empty": "El contenido del perfil personalizado está vacío", + "api.soul_library_not_found": "No se ha encontrado el perfil '{name}' en la biblioteca", + "api.soul_not_found": "No se ha encontrado el perfil", + "api.tool_not_found": "No se ha encontrado la herramienta '{name}'", + "cli_apps.abi_mismatch": ( + "La aplicación CLI {app!r} se instaló para {installed}, pero este entorno " + "usa {current}. Un administrador debe reinstalarla." + ), + "cli_apps.args_required": ( + "{tool} necesita un array 'args', con un argumento de la línea de comandos " + "por elemento." + ), + "cli_apps.entry_admin_only": ( + "Las aplicaciones CLI las instala un administrador; pídele que añada esta." + ), + "cli_apps.install_in_progress": "Esa aplicación ya se está instalando.", + "cli_apps.not_in_catalog": "No hay ninguna aplicación CLI llamada {id!r} en el catálogo.", + "cli_apps.not_installed": ( + "La aplicación CLI {app!r} ya no está instalada en este despliegue." + ), + "cli_apps.still_running": "{app} sigue en ejecución ({seconds} s)", + "mcp.configure_command_or_url": "Servidor {name!r}: configura un comando (stdio) o una URL.", + "mcp.configure_before_testing": "Configura un comando (stdio) o una URL antes de probar.", + "mcp.server_error": "Servidor {name!r}: {error}", + "mcp.server_missing": "No hay ningún servidor llamado {name!r} en tu lista.", + "mcp.not_oauth": "Este servidor no usa OAuth; proporciona una credencial.", + "mcp.oauth_callback_incomplete": "La respuesta de autorización estaba incompleta.", + "mcp.oauth_callback_unknown": ( + "Esa autorización ha caducado o ya se ha completado. Iníciala de nuevo." + ), + "mcp.oauth_done": "Autorización completada. Ya puedes cerrar esta pestaña.", + "mcp.oauth_failed": "La autorización ha fallado.", + "mcp.catalog_entry_missing": "No hay ningún servicio MCP llamado {id!r} en el catálogo.", + "mcp.entry_admin_only": ( + "Este servicio se ejecuta como un comando local y solo puede añadirlo un administrador." + ), + "mcp.tool_not_available": ( + "Esta herramienta no está disponible en esta conversación. " + "Solo se pueden usar las herramientas incluidas en el prompt." + ), + "sandbox.command_blocked": "Error: el comando ha sido bloqueado por la protección de seguridad.", + "sandbox.disabled_for_account": "La ejecución de código está desactivada para tu cuenta.", + "sandbox.no_backend": "no hay ningún entorno aislado disponible", + }, } diff --git a/deeptutor/i18n/languages.py b/deeptutor/i18n/languages.py new file mode 100644 index 0000000000..1ccb06517c --- /dev/null +++ b/deeptutor/i18n/languages.py @@ -0,0 +1,43 @@ +"""Canonical language registry shared by backend i18n surfaces.""" + +from __future__ import annotations + +from typing import Any + +DEFAULT_LANGUAGE = "en" +SUPPORTED_LANGUAGES = ("en", "zh", "es") + +_ALIASES = { + "en": "en", + "english": "en", + "zh": "zh", + "zh-cn": "zh", + "cn": "zh", + "chinese": "zh", + "es": "es", + "es-es": "es", + "spanish": "es", + "español": "es", +} + + +def normalize_supported_language( + language: Any, + default: str = DEFAULT_LANGUAGE, +) -> str: + """Return a supported base language, falling back to *default*.""" + if isinstance(language, str): + normalized = _ALIASES.get(language.strip().lower()) + if normalized: + return normalized + + if language != default: + return normalize_supported_language(default, DEFAULT_LANGUAGE) + return DEFAULT_LANGUAGE + + +__all__ = [ + "DEFAULT_LANGUAGE", + "SUPPORTED_LANGUAGES", + "normalize_supported_language", +] diff --git a/deeptutor/i18n/metadata_i18n.py b/deeptutor/i18n/metadata_i18n.py index 002fd214bf..7d84fdacd0 100644 --- a/deeptutor/i18n/metadata_i18n.py +++ b/deeptutor/i18n/metadata_i18n.py @@ -6,30 +6,37 @@ "chat": { "en": "Default agentic chat with tools, retrieval, memory, and attachments.", "zh": "默认智能聊天,支持工具、检索、记忆和附件。", + "es": "Chat inteligente predeterminado con herramientas, recuperación, memoria y adjuntos.", }, "deep_solve": { "en": "Multi-step problem solving with planning, reasoning, and final writing.", "zh": "多步骤解题,包含规划、推理和最终作答。", + "es": "Resolución de problemas en varios pasos: planificación, razonamiento y respuesta final.", }, "deep_question": { "en": "Generate high-quality questions from templates, sources, or learning goals.", "zh": "基于模板、资料或学习目标生成高质量题目。", + "es": "Genera preguntas de calidad a partir de plantillas, fuentes u objetivos de aprendizaje.", }, "deep_research": { "en": "Iterative deep research that decomposes a topic and writes a report.", "zh": "迭代式深度研究,分解主题并生成研究报告。", + "es": "Investigación profunda iterativa que descompone un tema y redacta un informe.", }, "math_animator": { "en": "Generate math animations or storyboard images with Manim.", "zh": "使用 Manim 生成数学动画或分镜图。", + "es": "Genera animaciones matemáticas o guiones gráficos con Manim.", }, "mastery_path": { "en": "Structured mastery-based learning with spaced repetition.", "zh": "结构化掌握式学习,结合间隔复习。", + "es": "Aprendizaje estructurado por dominio con repetición espaciada.", }, "visualize": { "en": "Create visual explanations such as SVG, charts, Mermaid, HTML, or Manim.", "zh": "生成 SVG、图表、Mermaid、HTML 或 Manim 等可视化讲解。", + "es": "Crea explicaciones visuales en SVG, gráficos, Mermaid, HTML o Manim.", }, "immersive_reading": { "en": "Read a document with the assistant, cited page by page.", @@ -41,38 +48,47 @@ "brainstorm": { "en": "Explore ideas broadly and organize them with rationale.", "zh": "广泛发散想法,并按理由组织结果。", + "es": "Explora ideas con amplitud y organízalas con su justificación.", }, "code_execution": { "en": "Run sandboxed Python code for computation and data exploration.", "zh": "在沙箱中运行 Python,用于计算和数据探索。", + "es": "Ejecuta Python en un entorno aislado para cálculos y exploración de datos.", }, "exec": { "en": "Run shell commands inside an isolated sandbox workspace.", "zh": "在隔离沙箱工作区中运行 shell 命令。", + "es": "Ejecuta comandos de terminal en un espacio de trabajo aislado.", }, "kb_files": { "en": "List the documents a knowledge base holds, with the total count.", "zh": "列出知识库中的文档清单与总数。", + "es": "Enumera los documentos de una base de conocimiento y su número total.", }, "paper_search": { "en": "Search arXiv preprints and return paper metadata.", "zh": "搜索 arXiv 预印本并返回论文元数据。", + "es": "Busca prepublicaciones en arXiv y devuelve sus metadatos.", }, "reason": { "en": "Use a dedicated reasoning model call for hard reasoning tasks.", "zh": "调用专门的推理模型处理高难度推理任务。", + "es": "Usa un modelo de razonamiento específico para tareas complejas.", }, "web_search": { "en": "Search the web and return sourced results.", "zh": "联网搜索并返回带来源的结果。", + "es": "Busca en la web y devuelve resultados con sus fuentes.", }, "imagegen": { "en": "Generate images from a text prompt with the configured model.", "zh": "用已配置的模型,根据文字描述生成图片。", + "es": "Genera imágenes a partir de una descripción con el modelo configurado.", }, "videogen": { "en": "Generate short videos from a text prompt with the configured model.", "zh": "用已配置的模型,根据文字描述生成短视频。", + "es": "Genera vídeos cortos a partir de una descripción con el modelo configurado.", }, } @@ -81,18 +97,18 @@ def capability_description_i18n(name: str, fallback: str = "") -> dict[str, str] values = _CAPABILITY_DESCRIPTIONS.get(name) if values: return dict(values) - return {"en": fallback, "zh": fallback} + return {"en": fallback, "zh": fallback, "es": fallback} def tool_description_i18n(name: str, fallback: str = "") -> dict[str, str]: values = _TOOL_DESCRIPTIONS.get(name) if values: return dict(values) - return {"en": fallback, "zh": fallback} + return {"en": fallback, "zh": fallback, "es": fallback} def localized_description(values: dict[str, str], language: str) -> str: - lang = "zh" if (language or "en").lower().startswith("zh") else "en" + lang = (language or "en").lower().split("-", 1)[0] return values.get(lang) or values.get("en") or values.get("zh") or "" diff --git a/deeptutor/services/prompt/language.py b/deeptutor/services/prompt/language.py index a4ce01295f..ad78abf346 100644 --- a/deeptutor/services/prompt/language.py +++ b/deeptutor/services/prompt/language.py @@ -14,7 +14,8 @@ "en": "English", "ja": "日本語", "ko": "한국어", - "es": "Español", + "es": "español de España", + "es-es": "español de España", "fr": "Français", "de": "Deutsch", "ru": "Русский", diff --git a/deeptutor/services/settings/interface_settings.py b/deeptutor/services/settings/interface_settings.py index 584e4f7905..4c65e40bf6 100644 --- a/deeptutor/services/settings/interface_settings.py +++ b/deeptutor/services/settings/interface_settings.py @@ -15,6 +15,7 @@ import threading from typing import Any +from deeptutor.i18n.languages import normalize_supported_language from deeptutor.services.path_service import get_path_service DEFAULT_UI_SETTINGS: dict[str, Any] = { @@ -53,25 +54,8 @@ def _interface_settings_file(): def _normalize_language(language: Any, default: str = "en") -> str: - """ - Normalize language codes: - - en/english -> en - - zh/chinese/cn -> zh - """ - if language is None or language == "": - language = default - - if isinstance(language, str): - s = language.lower().strip() - if s in {"en", "english"}: - return "en" - if s in {"zh", "chinese", "cn"}: - return "zh" - - # Fall back to default - if isinstance(default, str): - return _normalize_language(default, "en") - return "en" + """Normalize UI language codes against the shared registry.""" + return normalize_supported_language(language, default) def resolve_languages(saved: Mapping[str, Any]) -> dict[str, str]: diff --git a/tests/agents/chat/test_language_prompts.py b/tests/agents/chat/test_language_prompts.py index c87ef39c02..87450e9a14 100644 --- a/tests/agents/chat/test_language_prompts.py +++ b/tests/agents/chat/test_language_prompts.py @@ -42,11 +42,13 @@ def build_prompt_text(self, *_args, **_kwargs) -> str: ctx = UnifiedContext() zh_prompt = AgenticChatPipeline(language="zh")._build_system_prompt([], ctx) en_prompt = AgenticChatPipeline(language="en")._build_system_prompt([], ctx) + es_prompt = AgenticChatPipeline(language="es-ES")._build_system_prompt([], ctx) # Prompt blocks are phase-specific, but the shared language directive # still runs at the end, so per-language imperatives must surface. assert "请严格使用中文" in zh_prompt assert "Write ALL reader-facing text" in en_prompt + assert "strictly in español de España" in es_prompt # Persona phrasing differs by language so the prompts are not just # English text with a Chinese tail appended. assert "你是 DeepTutor" in zh_prompt diff --git a/tests/api/test_settings_router.py b/tests/api/test_settings_router.py index 55ca07bfaa..03e2e877d3 100644 --- a/tests/api/test_settings_router.py +++ b/tests/api/test_settings_router.py @@ -24,13 +24,13 @@ def test_load_ui_settings_migrates_legacy_language_to_response_language( monkeypatch: pytest.MonkeyPatch, tmp_path ) -> None: settings_file = tmp_path / "interface.json" - settings_file.write_text('{"theme": "snow", "language": "zh"}', encoding="utf-8") + settings_file.write_text('{"theme": "snow", "language": "es"}', encoding="utf-8") monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file) settings = settings_router.load_ui_settings() - assert settings["language"] == "zh" - assert settings["response_language"] == "zh" + assert settings["language"] == "es" + assert settings["response_language"] == "es" def test_both_readers_of_interface_json_agree_on_a_legacy_file( @@ -47,7 +47,7 @@ def test_both_readers_of_interface_json_agree_on_a_legacy_file( from deeptutor.services.settings import interface_settings settings_file = tmp_path / "interface.json" - settings_file.write_text('{"theme": "dark", "language": "zh"}', encoding="utf-8") + settings_file.write_text('{"theme": "dark", "language": "es"}', encoding="utf-8") monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file) monkeypatch.setattr(interface_settings, "_interface_settings_file", lambda: settings_file) @@ -55,7 +55,7 @@ def test_both_readers_of_interface_json_agree_on_a_legacy_file( from_service = interface_settings.get_ui_settings() for field in ("language", "response_language"): - assert from_router[field] == from_service[field] == "zh" + assert from_router[field] == from_service[field] == "es" @pytest.mark.asyncio @@ -66,11 +66,14 @@ async def test_ui_languages_are_persisted_independently( monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file) response = await settings_router.update_ui_settings( - settings_router.UISettingsUpdate(theme="snow", language="en", response_language="zh") + settings_router.UISettingsUpdate(language="es", response_language="zh") ) - assert response["language"] == "en" + assert response["language"] == "es" assert response["response_language"] == "zh" + persisted = json.loads(settings_file.read_text(encoding="utf-8")) + assert persisted["language"] == "es" + assert persisted["response_language"] == "zh" class _FakeEmbeddingAdapter: diff --git a/tests/i18n/test_languages.py b/tests/i18n/test_languages.py new file mode 100644 index 0000000000..3ee89b55c3 --- /dev/null +++ b/tests/i18n/test_languages.py @@ -0,0 +1,40 @@ +"""Tests for the extensible UI-language registry.""" + +import string + +from deeptutor.core.i18n import _MESSAGES +from deeptutor.i18n.languages import normalize_supported_language +from deeptutor.services.prompt.language import language_directive + + +def test_spanish_aliases_resolve_to_spanish() -> None: + for value in ("es", "ES", "es-ES", "Spanish", "español"): + assert normalize_supported_language(value) == "es" + + +def test_unknown_ui_language_falls_back_to_english() -> None: + assert normalize_supported_language("not-a-locale") == "en" + + +def test_spanish_model_directive_is_explicitly_from_spain() -> None: + directive = language_directive("es") + assert "español de España" in directive + assert "strictly" in directive + + +def _format_fields(value: str) -> set[str]: + return { + field_name + for _, field_name, _, _ in string.Formatter().parse(value) + if field_name is not None + } + + +def test_backend_catalogs_have_matching_keys_and_placeholders() -> None: + english = _MESSAGES["en"] + for language, messages in _MESSAGES.items(): + assert messages.keys() == english.keys(), f"key mismatch for {language}" + for key, source in english.items(): + assert _format_fields(messages[key]) == _format_fields(source), ( + f"placeholder mismatch for {language}:{key}" + ) diff --git a/web/app/(admin)/admin/users/page.tsx b/web/app/(admin)/admin/users/page.tsx index 0f4fc8029c..65f1607441 100644 --- a/web/app/(admin)/admin/users/page.tsx +++ b/web/app/(admin)/admin/users/page.tsx @@ -30,6 +30,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { formatDate as formatLocaleDate, type Language } from "@/lib/datetime"; +import { normalizeLanguage } from "@/i18n/languages"; // Delegates to the shared locale mapping so a new UI language only has to be // taught to lib/datetime; the guard here is for the empty or unparseable @@ -46,7 +47,7 @@ function formatDate(iso: string, lang: Language): string { export default function AdminUsersPage() { const router = useRouter(); const { t, i18n } = useTranslation(); - const lang: Language = i18n.language?.startsWith("zh") ? "zh" : "en"; + const lang: Language = normalizeLanguage(i18n.language); const [currentUser, setCurrentUser] = useState(null); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); diff --git a/web/app/(utility)/profile/page.tsx b/web/app/(utility)/profile/page.tsx index 03ea668d46..7b21ee89c9 100644 --- a/web/app/(utility)/profile/page.tsx +++ b/web/app/(utility)/profile/page.tsx @@ -24,6 +24,7 @@ import { } from "@/components/UserAvatar"; import { parseAvatarMarker } from "@/lib/avatar"; import { formatDate, type Language } from "@/lib/datetime"; +import { normalizeLanguage } from "@/i18n/languages"; const AVATAR_OUTPUT_SIZE = 256; // Decoding a huge photo just to throw away most pixels wastes memory; the @@ -198,7 +199,7 @@ export default function ProfilePage() { ? null : fallback.color; const isAdmin = profile?.role === "admin"; - const lang: Language = i18n.language?.startsWith("zh") ? "zh" : "en"; + const lang: Language = normalizeLanguage(i18n.language); const joinedDate = profile?.created_at ? new Date(profile.created_at) : null; const joined = joinedDate && !Number.isNaN(joinedDate.getTime()) diff --git a/web/app/(utility)/settings/appearance/page.tsx b/web/app/(utility)/settings/appearance/page.tsx index 0dea1b628a..f606d2c246 100644 --- a/web/app/(utility)/settings/appearance/page.tsx +++ b/web/app/(utility)/settings/appearance/page.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { ChevronDown, Languages } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { CodeBlockThemeId } from "@/components/common/code-block-themes"; @@ -15,6 +16,8 @@ import { selectClass, selectOptionClass, } from "@/components/settings/shared"; +import { APP_LANGUAGE_DEFINITIONS } from "@/i18n/languages"; +import type { AppLanguage } from "@/i18n/languages"; const CODE_BLOCK_PREVIEW_SNIPPET = `def fibonacci(n): """Generate the first n Fibonacci numbers.""" @@ -33,9 +36,46 @@ print(summary) const RichCodeBlockPreview = dynamic( () => import("@/components/common/RichCodeBlock"), - { ssr: false }, + { + ssr: false, + }, ); +function LanguageSelect({ + value, + label, + onChange, +}: { + value: AppLanguage; + label: string; + onChange: (language: AppLanguage) => void; +}) { + return ( +
+
+ ); +} + export default function AppearanceSettingsPage() { const { t } = useTranslation(); const { @@ -83,21 +123,11 @@ export default function AppearanceSettingsPage() { "Controls navigation, settings, and status text only.", )} control={ -
- {(["en", "zh"] as const).map((v) => ( - - ))} -
+ void updateLanguage(next)} + /> } /> - {(["en", "zh"] as const).map((value) => ( - - ))} - + void updateResponseLanguage(next)} + /> } /> diff --git a/web/app/(utility)/settings/tools/page.tsx b/web/app/(utility)/settings/tools/page.tsx index 3e72997971..685e23d6aa 100644 --- a/web/app/(utility)/settings/tools/page.tsx +++ b/web/app/(utility)/settings/tools/page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { useSettings } from "@/components/settings/SettingsContext"; import { SettingsPageHeader } from "@/components/settings/shared"; +import type { AppLanguage } from "@/i18n/languages"; import { apiFetch, apiUrl } from "@/lib/api"; import { invalidateEnabledOptionalToolsCache } from "@/lib/tools-settings"; @@ -32,7 +33,7 @@ type BuiltinTool = { name: string; description: string; parameters: ToolParameter[]; - hints: { en: ToolHints; zh: ToolHints }; + hints: Partial> & { en: ToolHints }; aliases: string[]; toggleable: boolean; enabled: boolean; @@ -255,7 +256,7 @@ export default function ToolsSettingsPage() {
{list.map((tool, idx) => { const isOpen = expanded.has(tool.name); - const hints = tool.hints[language]; + const hints = tool.hints[language] ?? tool.hints.en; const isPending = pending.has(tool.name); const isComingSoon = !!tool.coming_soon; const isEnabled = diff --git a/web/app/(workspace)/book/components/BookCreator.tsx b/web/app/(workspace)/book/components/BookCreator.tsx index c78f1d7c6f..3e5e687d13 100644 --- a/web/app/(workspace)/book/components/BookCreator.tsx +++ b/web/app/(workspace)/book/components/BookCreator.tsx @@ -85,8 +85,7 @@ const DEPTH_OPTIONS: Array<{ value: BookDepth; label: string; hint: string }> = type Translate = (key: string, options?: Record) => string; type ParentSelection = - | { mode: "all" } - | { mode: "subset"; ids: Set }; + { mode: "all" } | { mode: "subset"; ids: Set }; type ParentMap< TParent extends string | number, @@ -435,7 +434,9 @@ export default function BookCreator({ nbCount === 1 ? "{{count}} notebook record" : "{{count}} notebook records", - { count: nbCount }, + { + count: nbCount, + }, ), }); } @@ -452,7 +453,9 @@ export default function BookCreator({ icon: MessagesSquare, label: t( chatCount === 1 ? "{{count}} chat item" : "{{count}} chat items", - { count: chatCount }, + { + count: chatCount, + }, ), }); } diff --git a/web/components/quiz/QuizViewer.tsx b/web/components/quiz/QuizViewer.tsx index d25d614cd8..96b17e0333 100644 --- a/web/components/quiz/QuizViewer.tsx +++ b/web/components/quiz/QuizViewer.tsx @@ -42,6 +42,7 @@ import { } from "@/lib/quiz-judge"; import { type QuizQuestion } from "@/lib/quiz-types"; import CategoryMenu from "@/components/space/question-bank/CategoryMenu"; +import { normalizeLanguage } from "@/i18n/languages"; import { addEntryToCategory, createCategory, @@ -632,7 +633,7 @@ export default function QuizViewer({ })); setAnswerViews((prev) => ({ ...prev, [idx]: "judgment" })); - const judgeLanguage: "zh" | "en" = language === "zh" ? "zh" : "en"; + const judgeLanguage = normalizeLanguage(language); const handle = startQuizJudge( { @@ -929,7 +930,6 @@ export default function QuizViewer({ ) : null; })()} -
)} @@ -1287,9 +1287,7 @@ export default function QuizViewer({ > diff --git a/web/components/settings/ServiceConfigEditor.tsx b/web/components/settings/ServiceConfigEditor.tsx index 3d00dc2a16..45bab7ac74 100644 --- a/web/components/settings/ServiceConfigEditor.tsx +++ b/web/components/settings/ServiceConfigEditor.tsx @@ -17,6 +17,7 @@ import { import { useTranslation } from "react-i18next"; import ProviderIcon from "@/components/common/ProviderIcon"; +import type { AppLanguage } from "@/i18n/languages"; import { apiFetch, apiUrl } from "@/lib/api"; import { reasoningEffortOptions, @@ -1027,7 +1028,7 @@ export function ServiceConfigEditor({ service }: { service: ServiceName }) { ); } -function defaultModelLabel(language: "en" | "zh", index: number): string { +function defaultModelLabel(language: AppLanguage, index: number): string { const safeIndex = index > 0 ? index : 1; return language === "zh" ? `模型${safeIndex}` : `Model ${safeIndex}`; } diff --git a/web/components/settings/SettingsContext.tsx b/web/components/settings/SettingsContext.tsx index d53ea79bae..4f2c654d65 100644 --- a/web/components/settings/SettingsContext.tsx +++ b/web/components/settings/SettingsContext.tsx @@ -23,6 +23,7 @@ import { writeStoredResponseLanguage, } from "@/context/app-shell-storage"; import { useAppShell } from "@/context/AppShellContext"; +import type { AppLanguage } from "@/i18n/languages"; import { apiFetch, apiUrl } from "@/lib/api"; import { invalidateLLMOptionsCache } from "@/lib/llm-options"; import { setModelReasoningEffort } from "@/lib/reasoning-effort"; @@ -31,13 +32,7 @@ import { setTheme as applyThemePreference } from "@/lib/theme"; // ─── Domain types ───────────────────────────────────────────────────────── export type ServiceName = - | "llm" - | "embedding" - | "search" - | "tts" - | "stt" - | "imagegen" - | "videogen"; + "llm" | "embedding" | "search" | "tts" | "stt" | "imagegen" | "videogen"; export type CatalogModel = { id: string; @@ -116,8 +111,8 @@ export type Catalog = { export type UiSettings = { theme: "light" | "dark" | "glass" | "snow"; - language: "en" | "zh"; - response_language: "en" | "zh"; + language: AppLanguage; + response_language: AppLanguage; code_block_theme: string; code_block_show_line_numbers: boolean; code_block_wrap_long_lines: boolean; @@ -205,10 +200,7 @@ export type DiagnosticsResult = { }; export type ServiceReadiness = - | "not_configured" - | "untested" - | "passed" - | "failed"; + "not_configured" | "untested" | "passed" | "failed"; type SettingsPayload = { ui: UiSettings; @@ -649,7 +641,10 @@ export function SettingsProvider({ children }: { children: ReactNode }) { } setTheme(payload.ui.theme); setLanguage(payload.ui.language); - setResponseLanguage(payload.ui.response_language ?? payload.ui.language); + const loadedResponseLanguage = + payload.ui.response_language ?? payload.ui.language; + setResponseLanguage(loadedResponseLanguage); + writeStoredResponseLanguage(loadedResponseLanguage); // Writes the backend-loaded values into app-shell storage and dispatches // the code-block settings event; AppShellContext (the single source) picks // them up, so no separate copy needs seeding here. diff --git a/web/components/settings/shared.tsx b/web/components/settings/shared.tsx index 5772fba7c9..820b8c2247 100644 --- a/web/components/settings/shared.tsx +++ b/web/components/settings/shared.tsx @@ -1,5 +1,7 @@ "use client"; +import { localeForLanguage, type AppLanguage } from "@/i18n/languages"; + import type { CatalogProfile, ServiceName } from "./SettingsContext"; export const fieldControlClass = @@ -60,12 +62,12 @@ export function formatContextWindowSource( export function formatContextWindowUpdatedAt( value: string | undefined, - language: "en" | "zh", + language: AppLanguage, ): string { if (!value) return ""; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; - return parsed.toLocaleString(language === "zh" ? "zh-CN" : "en-US", { + return parsed.toLocaleString(localeForLanguage(language), { dateStyle: "medium", timeStyle: "short", }); @@ -95,7 +97,7 @@ export function activeModelDetail( // CJK glyphs are already square blocks so we drop both and bump size a hair. export function labelClass( size: "sm" | "md" | "lg", - language: "en" | "zh", + language: AppLanguage, ): string { if (language === "zh") { if (size === "sm") return "text-[10.5px] font-medium"; diff --git a/web/context/AppShellContext.tsx b/web/context/AppShellContext.tsx index e9bc3293f9..f6305cb537 100644 --- a/web/context/AppShellContext.tsx +++ b/web/context/AppShellContext.tsx @@ -26,6 +26,7 @@ import { LANGUAGE_EVENT, LANGUAGE_STORAGE_KEY, hasStoredLanguage, + hasStoredResponseLanguage, SIDEBAR_COLLAPSED_EVENT, SIDEBAR_COLLAPSED_STORAGE_KEY, normalizeCodeBlockShowLineNumbers, @@ -38,13 +39,13 @@ import { readStoredCodeBlockTheme, readStoredCodeBlockWrapLongLines, readStoredLanguage, - writeStoredResponseLanguage, readStoredSidebarCollapsed, writeStoredActiveSessionId, writeStoredCodeBlockShowLineNumbers, writeStoredCodeBlockTheme, writeStoredCodeBlockWrapLongLines, writeStoredLanguage, + writeStoredResponseLanguage, writeStoredSidebarCollapsed, type AppLanguage, } from "@/context/app-shell-storage"; @@ -99,17 +100,11 @@ export function AppShellProvider({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { - // The saved languages live in the backend's ui settings, but only the - // settings route ever read them, so every other page started in English - // until the user changed it again in this browser. Adopt them once, and - // only when this browser has made no choice of its own — a local selection - // is the more specific signal and must win. - // - // One fetch carries both fields: the interface locale and the - // reader-facing output language are stored together and are gated by the - // same "has this browser chosen yet?" question, so splitting them into two - // bootstraps would only give them a chance to disagree. - if (hasStoredLanguage()) return; + // Adopt each server-side language only when this browser has no more + // specific local choice for that preference. + const needsUiLanguage = !hasStoredLanguage(); + const needsResponseLanguage = !hasStoredResponseLanguage(); + if (!needsUiLanguage && !needsResponseLanguage) return; const controller = new AbortController(); void (async () => { try { @@ -122,20 +117,21 @@ export function AppShellProvider({ children }: { children: React.ReactNode }) { language?: unknown; response_language?: unknown; }; - if (payload.language !== "zh" && payload.language !== "en") return; - writeStoredLanguage(payload.language); - // A backend that predates the split sends no response_language; - // resolveResponseLanguage inherits the interface locale, matching what - // the server does for a legacy interface.json. - writeStoredResponseLanguage( - resolveResponseLanguage( - typeof payload.response_language === "string" - ? payload.response_language - : null, - payload.language, - ), - ); - setLanguageState(payload.language); + const uiLanguage = normalizeLanguage(payload.language); + if (needsUiLanguage) { + writeStoredLanguage(uiLanguage); + setLanguageState(uiLanguage); + } + if (needsResponseLanguage) { + writeStoredResponseLanguage( + resolveResponseLanguage( + typeof payload.response_language === "string" + ? payload.response_language + : null, + uiLanguage, + ), + ); + } } catch { // Offline or unauthenticated: keep the local default. } diff --git a/web/context/app-shell-storage.ts b/web/context/app-shell-storage.ts index acf8e34cbf..676439156b 100644 --- a/web/context/app-shell-storage.ts +++ b/web/context/app-shell-storage.ts @@ -1,6 +1,13 @@ "use client"; -export type AppLanguage = "en" | "zh"; +import { + APP_LANGUAGE_DEFINITIONS, + normalizeLanguage, + type AppLanguage, +} from "@/i18n/languages"; + +export type { AppLanguage } from "@/i18n/languages"; +export { normalizeLanguage } from "@/i18n/languages"; export const ACTIVE_SESSION_STORAGE_KEY = "deeptutor.activeSessionId.tab"; export const LANGUAGE_STORAGE_KEY = "deeptutor-language"; @@ -61,19 +68,17 @@ export const RESPONSE_LANGUAGE_EVENT = "deeptutor:response-language"; export const SIDEBAR_COLLAPSED_EVENT = "deeptutor:sidebar-collapsed"; export const CODE_BLOCK_SETTINGS_EVENT = "deeptutor:code-block-settings"; -export function normalizeLanguage( - value: string | null | undefined, -): AppLanguage { - return value === "zh" ? "zh" : "en"; -} - export function resolveResponseLanguage( value: string | null | undefined, legacyLanguage: string | null | undefined = "en", ): AppLanguage { - return value === "zh" || value === "en" - ? value - : normalizeLanguage(legacyLanguage); + const candidate = String(value ?? "") + .trim() + .toLowerCase(); + return ( + APP_LANGUAGE_DEFINITIONS.find(({ aliases }) => aliases.includes(candidate)) + ?.code ?? normalizeLanguage(legacyLanguage) + ); } export function readStoredLanguage(): AppLanguage { @@ -127,6 +132,15 @@ export function readStoredResponseLanguage(): AppLanguage { } } +export function hasStoredResponseLanguage(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(RESPONSE_LANGUAGE_STORAGE_KEY) !== null; + } catch { + return false; + } +} + export function writeStoredResponseLanguage(language: AppLanguage): void { if (typeof window === "undefined") return; try { diff --git a/web/i18n/init.ts b/web/i18n/init.ts index 7509c6489e..9014100ce7 100644 --- a/web/i18n/init.ts +++ b/web/i18n/init.ts @@ -3,14 +3,9 @@ import { initReactI18next } from "react-i18next"; import enApp from "@/locales/en/app.json"; -export type AppLanguage = "en" | "zh"; +import { normalizeLanguage, type AppLanguage } from "./languages"; -export function normalizeLanguage(lang: unknown): AppLanguage { - if (!lang) return "en"; - const s = String(lang).toLowerCase(); - if (s === "zh" || s === "cn" || s === "chinese") return "zh"; - return "en"; -} +export { normalizeLanguage, type AppLanguage } from "./languages"; let _initialized = false; @@ -46,5 +41,10 @@ export async function ensureLanguage(language: AppLanguage) { if (language === "zh") { const zhApp = (await import("@/locales/zh/app.json")).default; i18n.addResourceBundle("zh", "app", zhApp, true, true); + return; + } + if (language === "es") { + const esApp = (await import("@/locales/es/app.json")).default; + i18n.addResourceBundle("es", "app", esApp, true, true); } } diff --git a/web/i18n/languages.ts b/web/i18n/languages.ts new file mode 100644 index 0000000000..bde694919d --- /dev/null +++ b/web/i18n/languages.ts @@ -0,0 +1,53 @@ +export const APP_LANGUAGES = ["en", "zh", "es"] as const; + +export type AppLanguage = (typeof APP_LANGUAGES)[number]; + +export interface AppLanguageDefinition { + code: AppLanguage; + labelKey: string; + nativeLabel: string; + locale: string; + aliases: readonly string[]; +} + +export const APP_LANGUAGE_DEFINITIONS: readonly AppLanguageDefinition[] = [ + { + code: "en", + labelKey: "language.english", + nativeLabel: "English", + locale: "en-US", + aliases: ["en", "en-us", "english"], + }, + { + code: "zh", + labelKey: "language.chinese", + nativeLabel: "简体中文", + locale: "zh-CN", + aliases: ["zh", "zh-cn", "cn", "chinese"], + }, + { + code: "es", + labelKey: "language.spanishSpain", + nativeLabel: "Español (España)", + locale: "es-ES", + aliases: ["es", "es-es", "spanish", "español"], + }, +]; + +export function normalizeLanguage(language: unknown): AppLanguage { + const candidate = String(language ?? "") + .trim() + .toLowerCase(); + return ( + APP_LANGUAGE_DEFINITIONS.find(({ aliases }) => aliases.includes(candidate)) + ?.code ?? "en" + ); +} + +export function localeForLanguage(language: unknown): string { + const normalized = normalizeLanguage(language); + return ( + APP_LANGUAGE_DEFINITIONS.find(({ code }) => code === normalized)?.locale ?? + "en-US" + ); +} diff --git a/web/lib/datetime.ts b/web/lib/datetime.ts index 66b4c431e9..16eac9d8ba 100644 --- a/web/lib/datetime.ts +++ b/web/lib/datetime.ts @@ -1,8 +1,8 @@ -export type Language = "en" | "zh"; +import { localeForLanguage, type AppLanguage } from "@/i18n/languages"; -export function getLocale(lang: Language): string { - return lang === "zh" ? "zh-CN" : "en-US"; -} +export type Language = AppLanguage; + +export const getLocale = localeForLanguage; export function formatDate( date: Date, diff --git a/web/lib/quiz-judge.ts b/web/lib/quiz-judge.ts index 3d3b797621..3b9b8fa6a4 100644 --- a/web/lib/quiz-judge.ts +++ b/web/lib/quiz-judge.ts @@ -1,4 +1,5 @@ import { wsUrl } from "@/lib/api"; +import type { AppLanguage } from "@/i18n/languages"; export interface QuizJudgeImage { /** Base64 of the freshly-picked image (no ``data:`` prefix). */ @@ -18,7 +19,7 @@ export interface QuizJudgeRequest { user_answer: string; /** Multi-image list — backend builds a multimodal user message from this. */ user_answer_images: QuizJudgeImage[]; - language: "zh" | "en"; + language: AppLanguage; } export interface QuizJudgeHandle { diff --git a/web/locales/en/app.json b/web/locales/en/app.json index 778c9532ae..247bde6fbc 100644 --- a/web/locales/en/app.json +++ b/web/locales/en/app.json @@ -1,6 +1,7 @@ { "language.english": "English", "language.chinese": "中文", + "language.spanishSpain": "Español (España)", "Start": "Start", "Learn": "Learn", "Research": "Research", diff --git a/web/locales/en/common.json b/web/locales/en/common.json index 39b84582ae..65b7eb417b 100644 --- a/web/locales/en/common.json +++ b/web/locales/en/common.json @@ -1,6 +1,7 @@ { "language.english": "English", "language.chinese": "中文", + "language.spanishSpain": "Español (España)", "common.loading": "Loading...", "common.save": "Save", "common.cancel": "Cancel", diff --git a/web/locales/es/app.json b/web/locales/es/app.json new file mode 100644 index 0000000000..777481aaf1 --- /dev/null +++ b/web/locales/es/app.json @@ -0,0 +1,3242 @@ +{ + "language.english": "Inglés", + "language.chinese": "中文", + "language.spanishSpain": "Español (España)", + "Start": "Comenzar", + "Learn": "Aprender", + "Research": "Investigación", + "Dashboard": "Panel", + "Question Generator": "Generador de preguntas", + "Custom": "Personalizado", + "Mimic Exam": "Simular examen", + "Generating": "generando", + "questions": "preguntas", + "extended": "extendido", + "Custom Mode": "Modo personalizado", + "Mimic Exam Paper Mode": "Modo de simulación de examen", + "Generate questions based on knowledge base content": "Generar preguntas basadas en el contenido de la base de conocimientos.", + "Generate similar questions based on an exam paper": "Genera preguntas similares basadas en un examen", + "Knowledge Point / Topic": "Punto de conocimiento/tema", + "e.g. Gradient Descent Optimization": "p.ej. Optimización del descenso de gradiente", + "Count": "Cantidad", + "Generate Questions": "Generar preguntas", + "Questions": "Preguntas", + "Type your answer here...": "Escribe tu respuesta aquí...", + "Correct Answer": "Respuesta correcta", + "Explanation": "Explicación", + "Relevance Analysis": "Análisis de relevancia", + "round": "ronda", + "s": "s", + "Submit Answer": "Enviar respuesta", + "Submitted": "Enviado", + "Generating questions...": "Generando preguntas...", + "View progress in the Logs panel": "Ver el progreso en el panel Registros", + "Select a question to view details": "Selecciona una pregunta para ver los detalles", + "Brainstorm": "Lluvia de ideas", + "Code": "Código", + "Reason": "Razonamiento", + "Arxiv Search": "Buscar en arXiv", + "Papers": "Artículos", + "Deep Solve": "Resolución avanzada", + "Quiz Generation": "Generación de cuestionarios", + "Math Animator": "Animador matemático", + "Guided Learning": "Aprendizaje guiado", + "Flexible conversation with any tool": "Conversación flexible con cualquier herramienta.", + "Multi-step reasoning & problem solving": "Razonamiento de varios pasos y resolución de problemas", + "Auto-validated question generation": "Generación de preguntas validadas automáticamente", + "Comprehensive multi-agent research": "Investigación integral de múltiples agentes", + "Generate math videos or storyboard images": "Genera videos de matemáticas o imágenes de guiones gráficos", + "Framework v1.8.2 — diagnostic, mastery-based study, spaced repetition": "Framework v1.8.2: diagnóstico, estudio basado en el dominio, repetición espaciada", + "No KB": "Sin base de conocimientos", + "Enable Knowledge Base source first": "Activa primero la fuente de la base de conocimientos", + "Tools": "Herramientas", + "Loading models": "Cargando modelos", + "Models unavailable": "Modelos no disponibles", + "Select model": "Seleccionar modelo", + "Choose model": "Elige modelo", + "Choose a model": "Elige un modelo", + "Could not load models": "No se pudieron cargar los modelos", + "No configured models": "No hay modelos configurados", + "Applies to the next message in this chat": "Se aplica al siguiente mensaje en este chat.", + "System default": "Valor predeterminado del sistema", + "Use the active default model from Settings": "Usa el modelo predeterminado activo desde Configuración", + "Send": "Enviar", + "Drop images here": "Suelta imágenes aquí", + "Drop files here": "Suelta archivos aquí", + "Images, PDF, DOCX, XLSX, PPTX": "Imágenes, PDF, DOCX, XLSX, PPTX", + "Images, Office docs, code & text": "Imágenes, documentos de Office, código y texto", + "Unsupported file type: {{name}}": "Tipo de archivo no admitido: {{name}}", + "File too large: {{name}}": "Archivo demasiado grande: {{name}}", + "Too many files, skipped some": "Demasiados archivos, se omitieron algunos", + "Attachment preview": "Vista previa del archivo adjunto", + "Attachment": "Adjunto", + "Attach": "Adjuntar", + "Attach files": "Adjuntar archivos", + "Describe the math animation or storyboard you want...": "Describe la animación matemática o el guión gráfico que deseas...", + "Smart Solver": "Solucionador inteligente", + "Vision Solver": "Solucionador de visión", + "Upload a Math Problem Image": "Cargar una imagen de un problema matemático", + "Describe the problem or ask a question...": "Describe el problema o haz una pregunta...", + "Figure Reconstruction": "Reconstrucción de figuras", + "Verified by DeepTutor Vision Engine": "Verificado por DeepTutor Vision Engine", + "Analysis Details": "Detalles del análisis", + "Detected Elements": "Elementos detectados", + "Elements": "Elementos", + "Geometric Analysis": "Análisis geométrico", + "Constraints": "Restricciones", + "Relations": "Relaciones", + "Image is reference for problem": "La imagen es referencia para el problema.", + "Commands generated": "Comandos generados", + "Validation": "Validación", + "Issues found": "Problemas encontrados", + "Final commands": "Comandos finales", + "Upload an image to see analysis details": "Sube una imagen para ver los detalles del análisis", + "Analyzing Image": "Analizando imagen", + "Extracting geometric elements and generating visualization...": "Extrayendo elementos geométricos y generando visualización...", + "Element Detection": "Detección de elementos", + "Command Generation": "Generación de comandos", + "Found": "Encontró", + "elements": "elementos", + "constraints": "restricciones", + "relations": "relaciones", + "commands": "comandos", + "No issues found": "No se encontraron problemas", + "issues fixed": "problemas solucionados", + "Refresh": "Actualizar", + "Maximize": "Maximizar", + "Minimize": "Minimizar", + "Please select an image file": "Por favor selecciona un archivo de imagen", + "Image size must be less than 10MB": "El tamaño de la imagen debe ser inferior a 10 MB.", + "Enter image URL": "Introduce la URL de la imagen", + "Load": "Cargar", + "Failed to load image from URL": "No se pudo cargar la imagen desde la URL", + "Upload image (drag, paste, or click)": "Cargar imagen (arrastrar, pegar o hacer clic)", + "IdeaGen": "IdeaGen", + "Deep Research": "Investigación profunda", + "Welcome to Deep Research Lab. \n\nPlease configure your settings above, then enter a research topic below.": "Bienvenido al Laboratorio de Investigación Profunda. \n\nConfigura tus ajustes arriba y luego introduce un tema de investigación a continuación.", + "PDF Export failed": "Error al exportar PDF", + "Plan Mode": "Modo de planificación", + "Research Tools": "Herramientas de investigación", + "Topic Optimization": "Optimización de temas", + "Topic Assistant": "Asistente de tema", + "Start Research": "Iniciar investigación", + "Research in progress...": "Investigación en progreso...", + "Co-Writer": "Coescritor", + "Intelligent markdown editor with AI-powered writing assistance.": "Editor Markdown inteligente con asistencia de escritura mediante IA.", + "Knowledge": "Conocimiento", + "Memory": "Memoria", + "Knowledge Bases": "Bases de conocimiento", + "Refresh knowledge bases": "Actualizar bases de conocimientos", + "New Knowledge Base": "Nueva base de conocimientos", + "Set as Default": "Establecer como predeterminado", + "Upload Documents": "Cargar documentos", + "Delete Knowledge Base": "Eliminar base de conocimientos", + "Upload failed": "Error al subir el archivo", + "No knowledge bases found. Create one to get started.": "No se encontraron bases de conocimiento. Crea uno para comenzar.", + "Create Knowledge Base": "Crear base de conocimientos", + "Knowledge Base Name": "Nombre de la base de conocimientos", + "Notebooks": "Cuadernos", + "Settings": "Ajustes", + "Loading": "Cargando...", + "Loading conversation": "Cargando conversación…", + "Still loading…": "Todavía cargando...", + "Save": "Guardar", + "Cancel": "Cancelar", + "Error": "Error", + "Success": "Éxito", + "Unknown error": "Error desconocido", + "tokens": "tokens", + "View All": "Ver todo", + "Create": "Crear", + "Overview": "Descripción general", + "LLM": "LLM", + "Embedding": "Embeddings", + "TTS": "tts", + "Search": "Búsqueda", + "Snow": "Nieve", + "Light": "Claro", + "Cream": "Crema", + "Dark": "Oscuro", + "Glass": "Cristal", + "Configure your AI services and preferences": "Configura tus servicios y preferencias de IA", + "Local Data": "Datos locales", + "Clear Cache": "Borrar caché", + "Confirm Clear": "Confirmar Borrar", + "This will clear all locally cached data": "Esto borrará todos los datos almacenados en caché localmente.", + "Clear All": "Borrar todo", + "Including: chat history, solver history, question results, research reports, Co-Writer content, etc. This action cannot be undone.": "Incluyendo: historial de chat, historial de solucionadores, resultados de preguntas, informes de investigación, contenido de coautor, etc. Esta acción no se puede deshacer.", + "Model": "Modelo", + "Provider": "Proveedor", + "Port Configuration": "Configuración del puerto", + "Backend Port": "Puerto del backend", + "Frontend Port": "Puerto del frontend", + "LLM Configuration": "Configuración del LLM", + "Configure language model providers": "Configurar proveedores de modelos de lenguaje", + "Embedding Configuration": "Configuración de embedding", + "Configure embedding model providers": "Configurar proveedores de modelos de embedding", + "TTS Configuration": "Configuración TTS", + "Configure text-to-speech providers": "Configurar proveedores de texto a voz", + "Search Configuration": "Configuración de búsqueda", + "Configure web search providers": "Configurar proveedores de búsqueda web", + "Add Configuration": "Añadir configuración", + "Default": "Predeterminado", + "Set Active": "Establecer activo", + "Test": "Probar", + "Edit": "Editar", + "Delete": "Borrar", + "Use the arrows below to switch between branches.": "Usa las flechas a continuación para cambiar entre ramas.", + "No configurations found. Add one to get started.": "No se encontraron configuraciones. Añade uno para comenzar.", + "Are you sure you want to delete this configuration?": "¿Está seguro de que desea eliminar esta configuración?", + "Connection test failed": "La prueba de conexión falló", + "Name": "Nombre", + "My Configuration": "Mi configuración", + "local": "local", + "Base URL": "URL base", + "Use runtime settings": "Usar configuración de tiempo de ejecución", + "API Key": "Clave API", + "Show API key": "Mostrar clave API", + "Hide API key": "Ocultar clave API", + "optional for local providers": "opcional para proveedores locales", + "Not required": "No requerido", + "Base URL is required for testing": "Se requiere la URL base para realizar pruebas", + "API Key is required for cloud providers": "Se requiere clave API para proveedores de nube", + "Model name is required": "El nombre del modelo es obligatorio.", + "Dimensions is required for embedding models": "Se requieren dimensiones para incrustar modelos.", + "Connection successful": "Conexión exitosa", + "Connection failed": "La conexión falló", + "Connection test failed - network error": "La prueba de conexión falló: error de red", + "Failed to update configuration": "No se pudo actualizar la configuración", + "Failed to add configuration": "No se pudo añadir la configuración", + "Edit Configuration": "Editar configuración", + "Add New Configuration": "Añadir nueva configuración", + "Test Connection": "Probar conexión", + "Save Changes": "Guardar cambios", + "System Settings": "Configuración del sistema", + "Manage system configuration and preferences": "Administrar la configuración y las preferencias del sistema", + "General Settings": "Ajustes generales", + "Environment Variables": "Variables de entorno", + "Interface Preferences": "Preferencias de interfaz", + "Theme": "Tema", + "Light Mode": "Modo de luz", + "Dark Mode": "Modo oscuro", + "Language": "Idioma", + "English": "Inglés", + "Chinese": "Chino", + "System Configuration": "Configuración del sistema", + "System Language": "Idioma del sistema", + "Default language for system operations": "Idioma predeterminado para las operaciones del sistema", + "Web Search": "Búsqueda web", + "Max Results": "Resultados máximos", + "Knowledge Base": "Base de conocimientos", + "Default KB": "Base de conocimiento predeterminada", + "Base Directory": "Directorio base", + "Text-to-Speech": "Texto a voz", + "Speech-to-Text": "Voz a texto", + "Image Generation": "Generación de imágenes", + "Video Generation": "Generación de vídeo", + "Read assistant replies aloud from the chat speaker button. Works with any OpenAI-compatible audio API — OpenAI, OpenRouter, Groq, SiliconFlow, Azure, or a local server.": "Lea las respuestas del asistente en voz alta desde el botón del altavoz del chat. Funciona con cualquier API de audio compatible con OpenAI: OpenAI, OpenRouter, Groq, SiliconFlow, Azure o un servidor local.", + "Transcribe the chat composer's microphone recordings. Works with any OpenAI-compatible audio API — OpenAI, Groq, SiliconFlow, Azure, or a local server.": "Transcribe las grabaciones del micrófono del cuadro de chat. Funciona con cualquier API de audio compatible con OpenAI: OpenAI, Groq, SiliconFlow, Azure o un servidor local.", + "Text-to-image model used by the chat 'imagegen' tool. Works with any OpenAI-compatible /images/generations API — OpenAI, Volcengine Seedream, or compatible gateways.": "Modelo de texto a imagen utilizado por la herramienta de chat 'imagegen'. Funciona con cualquier API /images/generaciones compatible con OpenAI: OpenAI, Volcengine Seedream o puertas de enlace compatibles.", + "Text-to-video model used by the chat 'videogen' tool. Rendering is asynchronous and may take a minute or more. Uses async task providers like Volcengine Seedance.": "Modelo de texto a vídeo utilizado por la herramienta de chat 'videogen'. El renderizado es asincrónico y puede tardar un minuto o más. Utiliza proveedores de tareas asíncronas como Volcengine Seedance.", + "Playback": "Reproducción", + "How spoken replies behave in chat.": "Cómo se comportan las respuestas habladas en el chat.", + "Auto-play replies": "Respuestas de reproducción automática", + "Not set": "No establecido", + "Breadcrumb": "Ruta de navegación", + "Saved to": "Guardado en", + "Network changes take effect after restart.": "Los cambios de red entran en vigor después del reinicio.", + "Per-capability LLM parameters and runtime knobs.": "Parámetros LLM por capacidad y controles de tiempo de ejecución.", + "Read each assistant reply aloud automatically. You can also toggle this per conversation from the speaker button.": "Lea cada respuesta del asistente en voz alta automáticamente. También puedes alternar esto por conversación desde el botón del altavoz.", + "Default Voice": "Voz predeterminada", + "Default Language": "Idioma predeterminado", + "Active Models": "Modelos activos", + "Status": "Estado", + "Active LLM Model": "Modelo LLM activo", + "Not configured": "No configurado", + "Configure in Environment Variables tab": "Configurar en la pestaña Variables de entorno", + "Save All Changes": "Guardar todos los cambios", + "Configuration Saved": "Configuración guardada", + "Configuration Status": "Estado de configuración", + "Refresh Status": "Actualizar estado", + "Runtime Configuration": "Configuración de tiempo de ejecución", + "Environment variables are loaded from": "Las variables de entorno se cargan desde", + "file on startup": "archivo al inicio", + "Changes made here take effect immediately but are not saved to file": "Los cambios realizados aquí entran en vigor inmediatamente pero no se guardan en el archivo.", + "On restart, values will be reloaded from": "Al reiniciar, los valores se recargarán desde", + "Apply Environment Changes": "Aplicar cambios de entorno", + "Environment Updated!": "¡Entorno actualizado!", + "REQUIRED": "REQUERIDO", + "Error loading data": "Error al cargar datos", + "Failed to load settings": "No se pudo cargar la configuración", + "Failed to connect to backend": "No se pudo conectar al backend", + "Overview of your recent learning activities": "Resumen de tus actividades de aprendizaje recientes", + "Recent Activity": "Actividad reciente", + "Loading activities...": "Cargando actividades...", + "No recent activity found": "No se encontró actividad reciente", + "Start solving problems or generating questions!": "¡Empieza a resolver problemas o generar preguntas!", + "Problem Solved": "Problema resuelto", + "Question Generated": "Pregunta generada", + "Research Report": "Informe de investigación", + "Activity": "Actividad", + "My Notebooks": "Mis cuadernos", + "records": "registros", + "Solve": "Resolver", + "More Capabilities": "Más capacidades", + "Agent-loop driven modes": "Modos controlados por bucle de agente", + "Question": "Pregunta", + "No notebooks yet": "Aún no hay cuadernos", + "Create your first notebook": "Crea tu primer cuaderno", + "Create your first notebook above": "Crea tu primer cuaderno arriba", + "System Status": "Estado del sistema", + "Quick Actions": "Acciones Rápidas", + "Ask a Question": "Haz una pregunta", + "Generate Quiz": "Generar cuestionario", + "Home": "Inicio", + "History": "Historial", + "What would you like to learn?": "¿Qué te gustaría aprender?", + "Good morning.": "Buenos días.", + "Morning — let's learn something.": "Buenos días, aprendamos algo.", + "Good afternoon.": "Buenas tardes.", + "Afternoon — what's on your mind?": "Tarde, ¿qué tienes en mente?", + "Good evening.": "Buenas noches.", + "Evening — what shall we explore?": "Tarde: ¿qué exploraremos?", + "It's late today.": "Ya es tarde.", + "Burning the midnight oil?": "¿Trabajando hasta tarde?", + "Ask anything — I'm here to help you understand.": "Pregunta cualquier cosa: estoy aquí para ayudarte a comprender.", + "Welcome to DeepTutor": "Bienvenido a DeepTutor", + "How can I help you today?": "¿Cómo puedo ayudarte hoy?", + "Ask anything...": "Pregunta cualquier cosa...", + "Type your message...": "Escribe tu mensaje...", + "RAG": "RAG", + "Select Knowledge Base": "Seleccionar base de conocimientos", + "Explore Modules": "Explorar módulos", + "Smart Problem Solving": "Resolución inteligente de problemas", + "Generate Practice Questions": "Generar preguntas de práctica", + "Deep Research Reports": "Informes de investigación profunda", + "Generate Novel Ideas": "Generar ideas novedosas", + "Searching knowledge base...": "Buscando base de conocimientos...", + "Searching the web...": "Buscando en la web...", + "Generating response...": "Generando respuesta...", + "Clear Chat": "Borrar chat", + "New Session": "Nueva sesión", + "New": "Nuevo", + "Ask a difficult question...": "Haz una pregunta difícil...", + "DeepTutor can make mistakes. Please verify important information.": "DeepTutor puede cometer errores. Por favor verifique información importante.", + "Logic Stream": "Flujo lógico", + "Running": "En ejecución", + "I can help you solve complex STEM problems using multi-step reasoning. Try asking about calculus, physics, or coding algorithms.": "Puedo ayudarte a resolver problemas STEM complejos utilizando un razonamiento de varios pasos. Intente preguntar sobre cálculo, física o algoritmos de codificación.", + "Sources": "Fuentes", + "RAG Enabled": "RAG habilitado", + "Web Search Enabled": "Búsqueda web habilitada", + "From Knowledge Base": "De la base de conocimientos", + "From Web": "Desde la Web", + "New Chat": "Nuevo chat", + "New chat": "Nuevo chat", + "Save to Notebook": "Guardar en el cuaderno", + "Download Markdown": "Descargar Markdown", + "Download chat history as Markdown": "Descargar el historial de chat como Markdown", + "Saved to notebook.": "Guardado en el cuaderno.", + "Add some content before saving to a notebook.": "Añade contenido antes de guardarlo en un cuaderno.", + "Untitled Co-Writer Document": "Documento de coautor sin título", + "Click to edit": "Haz clic para editar", + "Expand sidebar": "Expandir barra lateral", + "Collapse sidebar": "Contraer barra lateral", + "Collapse left panel": "Contraer panel izquierdo", + "Search notebooks...": "Buscar cuadernos...", + "Loading records...": "Cargando registros...", + "No records in this notebook": "No hay registros en este cuaderno.", + "Chat Session": "Sesión de chat", + "User": "Usuario", + "Assistant": "Asistente", + "Multi-agent reasoning": "Razonamiento multiagente", + "Auto-validated quizzes": "Cuestionarios validados automáticamente", + "Comprehensive analysis": "Análisis completo", + "Brainstorm & synthesize": "Lluvia de ideas y síntesis", + "Step-by-step tutoring": "Tutoría paso a paso", + "Collaborative writing": "Escritura colaborativa", + "Collection of Research Ideas": "Colección de ideas de investigación", + "Research ideas containing {n} knowledge points": "Ideas de investigación que contienen {n} puntos de conocimiento", + "Select Source (Cross-Notebook)": "Seleccionar fuente de varios cuadernos", + "Clear": "Limpiar", + "No notebooks with records found": "No se encontraron cuadernos con registros.", + "Your Thoughts": "Tus pensamientos", + "Optional": "Opcional", + "Required": "Requerido", + "Describe your thoughts or research direction based on these materials...": "Describe tus pensamientos o dirección de investigación basada en estos materiales...", + "Describe your research topic or idea (no notebook selection needed)...": "Describe tu tema o idea de investigación (no es necesario seleccionar un cuaderno)...", + "You can generate ideas from text description alone, or select notebook records above for richer context.": "Puede generar ideas solo a partir de la descripción del texto o seleccionar los registros del cuaderno de arriba para obtener un contexto más rico.", + "Generating...": "Generando...", + "Discover research ideas from your notes": "Descubre ideas de investigación a partir de tus notas", + "Select All": "Seleccionar todo", + "Save Selected": "Guardar selección", + "Generate Ideas ({n} items)": "Generar ideas ({n} elementos)", + "Generate Ideas (Text Only)": "Generar ideas (solo texto)", + "Chat History": "Historial de chat", + "Recents": "Recientes", + "Show recents": "Mostrar recientes", + "Hide recents": "Ocultar recientes", + "Docs": "Documentos", + "Solver History": "Historial de resolución", + "All Activities": "Todas las actividades", + "Filter by type": "Filtrar por tipo", + "Previous branch": "Rama anterior", + "Next branch": "Rama siguiente", + "Dialog": "Diálogo", + "Reference space": "Espacio de referencia", + "Connection lost while generating. Please retry your message.": "Conexión perdida durante la generación. Vuelve a intentar enviar tu mensaje.", + "Couldn't reach the server. Please check your connection and retry.": "No se pudo comunicar con el servidor. Por favor verifique tu conexión y vuelve a intentarlo.", + "Today": "Hoy", + "Yesterday": "Ayer", + "Last 7 days": "últimos 7 días", + "Earlier": "Más temprano", + "Sign in": "Iniciar sesión", + "Sign in to your account": "Inicia sesión en tu cuenta", + "Signing in…": "Iniciando sesión…", + "Login failed": "Error al iniciar sesión", + "Email or username": "Correo electrónico o nombre de usuario", + "Password": "Contraseña", + "Don't have an account?": "¿No tienes una cuenta?", + "Create one": "Crea uno", + "Account created! Sign in to continue.": "¡Cuenta creada! Inicia sesión para continuar.", + "Create your account": "Crea tu cuenta", + "Create account": "Crear una cuenta", + "Creating account…": "Creando cuenta…", + "Confirm password": "Confirmar Contraseña", + "Passwords do not match": "Las contraseñas no coinciden", + "Registration failed": "Registro fallido", + "Already have an account?": "¿Ya tienes una cuenta?", + "At least 8 characters": "Al menos 8 caracteres", + "First user:": "Primer usuario:", + "You will be granted admin privileges and can manage other users from the admin dashboard.": "Se le otorgarán privilegios de administrador y podrá administrar a otros usuarios desde el panel de administración.", + "session": "sesión", + "sessions": "sesiones", + "Chat": "Chat", + "No history found": "No se encontró ningún historial", + "Your activities will appear here": "Tus actividades aparecerán aquí", + "Continue": "Continuar", + "View": "Ver", + "Close": "Cerrar", + "messages": "mensajes", + "Failed to load session": "No se pudo cargar la sesión", + "Added Successfully!": "Añadido correctamente", + "Record Preview": "Vista previa del registro", + "Select Notebooks": "Seleccionar cuadernos", + "New Notebook": "Nuevo cuaderno", + "Notebook name": "Nombre del cuaderno", + "Color:": "Color:", + "Create & Initialize": "Crear e inicializar", + "Loading notebooks...": "Cargando cuadernos...", + "Record has been saved to {n} notebook": "El registro se ha guardado en el cuaderno {n}", + "Record has been saved to {n} notebooks": "El registro se ha guardado en {n} cuadernos", + "{n} notebook selected": "{n} cuaderno seleccionado", + "{n} notebooks selected": "{n} cuadernos seleccionados", + "Select at least one notebook": "Selecciona al menos un cuaderno", + "Saving...": "Guardando...", + "Import from Notebooks": "Importar desde cuadernos", + "Select content from your notebooks to import": "Selecciona contenido de tus cuadernos para importar", + "No notebooks found": "No se encontraron cuadernos", + "Select a notebook to view records": "Selecciona un cuaderno para ver sus registros", + "Selected {n} items": "{n} elementos seleccionados", + "Import Selected": "Importar selección", + "Backend Service": "Servicio backend", + "Backend status": "Estado del servidor", + "LLM Model": "Modelo LLM", + "Embeddings": "Embeddings", + "TTS Model": "Modelo TTS", + "Checking...": "Comprobando...", + "Checking": "Comprobando", + "Online": "En línea", + "Offline": "Desconectado", + "Configured": "Configurado", + "Not Configured": "No configurado", + "Unknown": "Desconocido", + "Testing...": "Probando...", + "Test failed": "Prueba fallida", + "Link Folder": "Vincular carpeta", + "Link Local Folder": "Vincular carpeta local", + "Folder Path": "Ruta de la carpeta", + "Paste or type the full folder path": "Pega o escribe la ruta completa de la carpeta", + "Supported files: PDF, DOCX, TXT, MD": "Archivos compatibles: PDF, DOCX, TXT, MD", + "New and modified files will be automatically detected when you sync.": "Los archivos nuevos y modificados se detectarán automáticamente cuando sincronices.", + "Folder linked successfully!": "¡Carpeta vinculada exitosamente!", + "Failed to link folder": "No se pudo vincular la carpeta", + "Activity Details": "Detalles de la actividad", + "Type": "Tipo", + "Final Answer": "Respuesta final", + "Parameters": "Parámetros", + "Generated Question": "Pregunta generada", + "Correct Answer & Explanation": "Respuesta correcta y explicación", + "Answer:": "Respuesta:", + "No explanation provided": "No se proporcionó ninguna explicación", + "Topic:": "Tema:", + "Difficulty:": "Dificultad:", + "Type:": "Tipo:", + "Topic": "Tema", + "Report Preview": "Vista previa del informe", + "N/A": "N/D", + "No question content": "Contenido sin preguntas", + "Workspace": "ESPACIO DE TRABAJO", + "Learn & Research": "Aprender e investigar", + "✨ Your description here": "✨Tu descripción aquí", + "Enter your description...": "Introduce tu descripción...", + "Visit DeepTutor Homepage": "Visita la página de inicio de DeepTutor", + "View on GitHub": "Ver en GitHub", + "KB": "KB", + "Solution image": "Imagen de la solución", + "Count:": "Cantidad:", + "Initializing": "Inicializando", + "Generating Search Queries": "Generar consultas de búsqueda", + "Retrieving Knowledge": "Recuperando conocimiento", + "Creating Question Plan": "Creando un plan de preguntas", + "Uploading PDF": "Subiendo PDF", + "Parsing PDF (MinerU)": "Análisis de PDF (MinerU)", + "Extracting Questions": "Extrayendo preguntas", + "Ready to Generate": "Listo para generar", + "Generation Complete": "Generación completa", + "Progress": "Progreso", + "Question Focuses ({n})": "Enfoques de preguntas ({n})", + "Logs ({n})": "Registros ({n})", + "Clear logs": "Borrar registros", + "Waiting for logs...": "Esperando registros...", + "Mimic Exam Mode": "Modo de simulación de examen", + "Generating questions based on reference exam paper": "Generar preguntas basadas en el examen de referencia", + "Learning Assistant": "Asistente de aprendizaje", + "Have any questions? Feel free to ask...": "¿Tiene alguna pregunta? No dudes en preguntar...", + "Learning Summary": "Resumen de aprendizaje", + "Fix HTML Issue": "Solucionar problema de HTML", + "Issue Description": "Descripción del problema", + "Describe the HTML issue, e.g.: button not clickable, style display error, interaction not working...": "Describe el problema de HTML, por ejemplo: no se puede hacer clic en el botón, error de visualización del estilo, interacción que no funciona...", + "Fixing...": "Corrigiendo...", + "Fix": "Arreglar", + "Loading learning content...": "Cargando contenido de aprendizaje...", + "Fix HTML": "Reparar HTML", + "Interactive Learning Content": "Contenido de aprendizaje interactivo", + "Generate Learning Plan ({n} items)": "Generar plan de aprendizaje ({n} elementos)", + "Learning Progress": "Progreso del aprendizaje", + "Knowledge Point {n} / {total}": "Punto de conocimiento {n} / {total}", + "Start Learning": "Iniciar aprendizaje", + "Loading...": "Cargando...", + "Next": "Próximo", + "Generating Summary...": "Generando resumen...", + "Complete Learning": "Completar aprendizaje", + "Switch to narrow sidebar (1:3)": "Cambiar a barra lateral estrecha (1:3)", + "Switch to wide sidebar (3:1)": "Cambiar a barra lateral ancha (3:1)", + "Select a notebook, and the system will generate a personalized learning plan. Through interactive pages and intelligent Q&A, you'll gradually master all the content.": "Selecciona un cuaderno y el sistema generará un plan de aprendizaje personalizado. A través de páginas interactivas y preguntas y respuestas inteligentes, poco a poco irás dominando todo el contenido.", + "Select notebook records or describe your research topic": "Selecciona registros de cuaderno o describe tu tema de investigación", + "Manage and explore your educational content repositories.": "Gestiona y explore tus repositorios de contenido educativo.", + "RAG-Anything": "LlamaIndex", + "RAG-Anything (Docling)": "LlamaIndex", + "Documents": "Documentos", + "document": "documento", + "documents": "documentos", + "Images": "Imágenes", + "Ready": "Listo", + "Processing": "Procesando", + "Processing File": "Procesando archivo", + "Extracting Items": "Extrayendo elementos", + "Not Indexed": "No indexado", + "e.g., Math101": "por ejemplo, Math101", + "Pure vector retrieval, fastest processing speed.": "Recuperación de vectores puros, velocidad de procesamiento más rápida.", + "Lightweight knowledge graph retrieval, fast processing of text documents.": "Reservado para futuros proveedores.", + "Multimodal document processing with chart and formula extraction, builds knowledge graphs.": "Reservado para futuros proveedores.", + "Select a RAG pipeline suitable for your document type": "Selecciona un proveedor RAG (actualmente solo LlamaIndex)", + "Click or drop to add more files": "Haz clic o arrastra aquí más archivos", + "Drag & drop files or folders here": "Arrastra y suelte archivos o carpetas aquí", + "{n} file selected": "{n} archivo seleccionado", + "{n} files selected": "{n} archivos seleccionados", + "Clear all": "Borrar todo", + "Remove file": "Eliminar archivo", + "Upload documents to": "Subir documentos a", + "RAG Provider": "Proveedor de RAG", + "RAG Provider (Optional)": "Proveedor RAG (opcional)", + "Leave as-is to use the KB's existing provider": "Déjelo como está para usar el proveedor existente de la base de conocimiento.", + "Keep unchanged to use this KB's existing provider": "Mantener sin cambios para usar el proveedor existente de esta KB", + "Clear progress status": "Borrar estado de progreso", + "DeepTutor Logo": "Logotipo de DeepTutor", + "Optimizing topic...": "Optimizando tema...", + "Verified by DeepTutor Logic Engine": "Verificado por DeepTutor Logic Engine", + "Model:": "Modelo:", + "Activity Log": "Registro de actividad", + "Waiting for logic execution...": "Esperando ejecución lógica...", + "gpt-4o": "gpt-4o", + "Link a local folder to": "Vincular una carpeta local a", + ". Documents in the folder will be processed and added to this knowledge base.": ". Los documentos de la carpeta se procesarán y añadirán a esta base de conocimientos.", + "C:\\Users\\name\\Documents\\papers": "C:\\Users\\name\\Documents\\papers", + "📄 Supported files: PDF, DOCX, TXT, MD": "📄 Archivos compatibles: PDF, DOCX, TXT, MD", + "💡 Tip:": "💡 Consejo:", + "Use folders synced with Google Drive, OneDrive, SharePoint, or Dropbox for automatic cloud integration.": "Usa carpetas sincronizadas con Google Drive, OneDrive, SharePoint o Dropbox para la integración automática en la nube.", + "Diagram rendering error": "Error de representación del diagrama", + "Show source": "Mostrar fuente", + "Planning": "Planificando", + "Researching": "investigando", + "Reporting": "Generando informe", + "Planning Research Strategy": "Planificación de la estrategia de investigación", + "Research Plan": "Plan de investigación", + "Original Topic": "Tema original", + "Optimized Topic": "Tema optimizado", + "No research data yet": "Aún no hay datos de investigación", + "(History)": "(Historial)", + "Report Generated!": "¡Informe generado!", + "Generating Report": "Generando informe", + "Report Generation": "Generación de informes", + "Currently Writing": "Actualmente escribiendo", + "Report Outline": "Esquema del informe", + "View Full Report": "Ver informe completo", + "Parallel Mode": "Modo paralelo", + "AI Edit Assistant": "Asistente de edición AI", + "Close dialog": "Cerrar diálogo", + "Instruction (Optional)": "Instrucción (opcional)", + "e.g. Make it more formal...": "p.ej. Hazlo más formal...", + "Context Source (Optional)": "Fuente de contexto (opcional)", + "Web": "Web", + "Rewrite": "Volver a escribir", + "Shorten": "Acortar", + "AI Mark": "Marca de IA", + "Processing...": "Procesando...", + "Apply": "Aplicar", + "Live Preview · Synced Scroll": "Vista previa en vivo · Desplazamiento sincronizado", + "Show AI Marks": "Mostrar marcas de IA", + "Hide AI Marks": "Ocultar marcas de IA", + "Preview": "Avance", + "Updates live as you change the settings below.": "Las actualizaciones se activan a medida que cambia la configuración a continuación.", + "Process": "Proceso", + "Report": "Informe", + "Ready to Research": "Listo para investigar", + "Enter a topic in the left panel to start deep research.": "Introduce un tema en el panel izquierdo para comenzar una investigación profunda.", + "Markdown": "Markdown", + "Generating report preview...": "Generando vista previa del informe...", + "Select a task to view execution details": "Selecciona una tarea para ver los detalles de ejecución", + "Waiting for execution logs...": "Esperando registros de ejecución...", + "No tasks initialized yet": "Aún no se han inicializado tareas", + "Current Action:": "Acción actual:", + "Waiting to start...": "Esperando empezar...", + "Tools:": "Herramientas:", + "User Query": "Consulta de usuario", + "Output": "Producción", + "Metadata": "Metadatos", + "No records yet": "Aún no hay registros", + "Add records from Solver, Question, Research, or Co-Writer": "Añade registros de Solver, Question, Research o Co-Writer", + "Select a record to view details": "Selecciona un registro para ver los detalles", + "Create New Notebook": "Crear nuevo cuaderno", + "Description (Optional)": "Descripción (opcional)", + "Color": "Color", + "Edit Notebook": "Editar cuaderno", + "Description": "Descripción", + "Delete Notebook?": "¿Eliminar cuaderno?", + "Import Records": "Importar registros", + "Source Notebook": "Cuaderno fuente", + "Select a notebook...": "Selecciona un cuaderno...", + "Expand left panel": "Expandir el panel izquierdo", + "Collapse middle panel": "Contraer panel central", + "Expand middle panel": "Expandir el panel central", + "Collapse right panel": "Contraer panel derecho", + "Expand right panel": "Expandir el panel derecho", + "Export as Markdown": "Exportar como Markdown", + "Export as PDF": "Exportar como PDF", + "Import records from other notebooks": "Importar registros de otros cuadernos", + "My Notebook": "mi cuaderno", + "Notes about machine learning...": "Notas sobre el aprendizaje automático...", + "This action cannot be undone. All records in this notebook will be permanently deleted.": "Esta acción no se puede deshacer. Todos los registros de este cuaderno se eliminarán permanentemente.", + "Logs": "Registros", + "Difficulty": "Dificultad", + "Easy": "Fácil", + "Medium": "Medio", + "Hard": "Duro", + "Multiple Choice": "Opción múltiple", + "Written": "Escrito", + "Upload Exam Paper (PDF)": "Cargar documento de examen (PDF)", + "Click to upload PDF": "Haz clic para cargar PDF", + "The system will parse and generate questions": "El sistema analizará y generará preguntas.", + "OR": "O", + "Pre-parsed Directory": "Directorio previamente analizado", + "KB Coverage": "Cobertura de la base de conocimiento", + "Extension Points": "Puntos de extensión", + "KB Connection": "Conexión KB", + "Extended Aspects": "Aspectos ampliados", + "Reasoning": "Razonamiento", + "e.g. 2211asm1": "p.ej. 2211asm1", + "Please upload a PDF exam paper": "Por favor cargue un examen en PDF", + "Remove PDF": "Eliminar PDF", + "Podcast Narration": "Narración del podcast", + "Click to expand": "Haz clic para ampliar", + "Script only (TTS not configured)": "Solo script (TTS no configurado)", + "Current note is empty, cannot generate narration.": "La nota actual está vacía, no se puede generar una narración.", + "Failed to generate narration, please try again.": "No se pudo generar la narración. Vuelve a intentarlo.", + "Generating Podcast": "Generando podcasts", + "Generate Podcast": "Generar podcast", + "Save Podcast to Notebook": "Guardar pódcast en un cuaderno", + "After generation, the narration script will appear here.": "Después de la generación, el guión de la narración aparecerá aquí.", + "Key Points": "Puntos clave", + "After generation, 3-5 key points will be listed here.": "Después de la generación, se enumerarán aquí de 3 a 5 puntos clave.", + "Podcast Audio": "Audio de podcasts", + "Your browser does not support the audio element.": "tu navegador no soporta el elemento de audio.", + "TTS not configured, script generation only.": "TTS no configurado, solo generación de script.", + "After generation, you can play the podcast audio here.": "Después de la generación, puedes reproducir el audio del podcast aquí.", + "Cmd+S to save · Markdown supported": "Cmd+S para guardar · Compatible con Markdown", + "Refresh from a session or write directly in the editor.": "Actualiza desde una sesión o escribe directamente en el editor.", + "Manage your knowledge bases and notebooks in one place.": "Gestiona tus bases de conocimientos y cuadernos en un solo lugar.", + "Create knowledge base": "Crear base de conocimientos", + "Knowledge base name": "Nombre de la base de conocimientos", + "Choose files...": "Elige archivos...", + "Create Process": "Crear proceso", + "Upload documents": "Subir documentos", + "Select a knowledge base": "Selecciona una base de conocimientos", + "No ready knowledge base is available for upload. Create a new KB or reindex legacy KBs first.": "No hay ninguna base de conocimientos lista para cargar. Primero crea una nueva KB o reindexe las KB heredadas.", + "Upload": "Subir", + "Upload Process": "Proceso de carga", + "Knowledge bases": "Bases de conocimiento", + "needs reindex": "necesita reindexar", + "Set default": "Establecer predeterminado", + "No knowledge bases yet. Create one to get started.": "Aún no hay bases de conocimiento. Crea uno para comenzar.", + "Create notebook": "Crear cuaderno", + "No notebooks yet. Create one to organize outputs.": "Aún no hay cuadernos. Crea uno para organizar los resultados.", + "This notebook is empty for now.": "Este cuaderno está vacío por ahora.", + "Select a notebook to inspect its saved records.": "Selecciona un cuaderno para revisar sus registros.", + "Unknown time": "Hora desconocida", + "Open chat session": "Abrir sesión de chat", + "Query:": "Consulta:", + "Delete knowledge base \"{{name}}\"?": "¿Eliminar la base de conocimientos \"{{name}}\"?", + "This knowledge base is in legacy index format and needs reindex before upload.": "Esta base de conocimientos está en formato de índice heredado y es necesario volver a indexarla antes de cargarla.", + "This knowledge base is currently {{status}} and cannot accept uploads yet.": "Esta base de conocimientos actualmente es {{status}} y aún no puede aceptar cargas.", + "1 / 4 — LLM": "1 / 4 — LLM", + "Configure your language model endpoint. This powers all chat and reasoning.": "Configura el endpoint de tu modelo de lenguaje. Esto impulsa todo el chat y el razonamiento.", + "2 / 4 — Embedding": "2/4 - Embedding", + "Set the embedding model for knowledge retrieval.": "Establece el modelo de embedding para la recuperación de conocimientos.", + "3 / 4 — Search": "3/4 — Buscar", + "Optional: add a web search provider for real-time information.": "Opcional: añade un proveedor de búsqueda web para obtener información en tiempo real.", + "4 / 4 — Complete": "4/4 - Completo", + "When you are ready, click here to test and launch DeepTutor.": "Cuando esté listo, haz clic aquí para probar e iniciar DeepTutor.", + "Skip tour": "Saltar recorrido", + "Got it": "Entiendo", + "Passed": "Aprobado", + "Failed": "Fallido", + "Skipped": "Saltado", + "Running tests...": "Ejecutando pruebas...", + "Test Results": "Resultados de la prueba", + "Back to editing": "Volver a editar", + "Launch anyway": "Lanzar de todos modos", + "Confirm & Launch": "Confirmar y lanzar", + "Please wait...": "Espera, por favor...", + "Draft saved": "Borrador guardado", + "Applied to runtime settings": "Aplicado a la configuración del tiempo de ejecución", + "Diagnostics stream disconnected": "Flujo de diagnóstico desconectado", + "Failed to complete tour": "No se pudo completar el recorrido", + "Run {{command}} in your terminal.": "Ejecuta {{command}} en tu terminal.", + "Run python scripts/start_tour.py in your terminal.": "Ejecuta python scripts/start_tour.py en tu terminal.", + "Setup Tour": "Tour de instalación", + "Configure your endpoints below, run tests, then launch DeepTutor.": "Configura los endpoints, ejecuta las pruebas y después inicia DeepTutor.", + "Complete & Launch": "Completar y lanzar", + "Configuration saved": "Configuración guardada", + "Redirecting to DeepTutor in {{count}}s...": "Redirigiendo a DeepTutor en {{count}}s...", + "Redirecting...": "Redirigiendo...", + "Draft has unsaved changes": "El borrador tiene cambios no guardados", + "All changes saved": "Todos los cambios guardados", + "Save Draft": "Guardar borrador", + "Complete the tour first": "Completa el recorrido primero", + "Backend": "backend", + "Emb": "embe", + "Profile": "Perfil", + "No endpoint": "Sin endpoint", + "Delete profile": "Eliminar perfil", + "Provider Hint / Binding": "Proveedor sugerido / adaptador", + "Select provider...": "Selecciona proveedor...", + "Supported provider.": "Proveedor compatible.", + "Required — without it, search falls back to DuckDuckGo.": "Obligatorio: sin él, la búsqueda vuelve a DuckDuckGo.", + "API Version": "Versión API", + "Proxy": "Apoderado", + "Extra Headers (JSON)": "Encabezados adicionales (JSON)", + "Models": "Modelos", + "Sync models": "Sincronizar modelos", + "No models returned": "No se ha devuelto ningún modelo", + "Synced {{count}} models": "{{count}} modelos sincronizados", + "Label": "Etiqueta", + "Model ID": "ID del modelo", + "Context Window": "Ventana de contexto", + "Detected at": "Detectado en", + "Unset": "Desarmado", + "Known": "Conocido", + "Detected from the provider during the latest LLM test and saved into model_catalog.json.": "Detectado por el proveedor durante la última prueba de LLM y guardado en model_catalog.json.", + "Matched built-in model metadata during the latest LLM test and saved into model_catalog.json.": "Metadatos del modelo integrado coincidentes durante la última prueba de LLM y guardados en model_catalog.json.", + "The provider did not expose a context window, so the runtime fallback was saved during the latest LLM test.": "El proveedor no expuso una ventana contextual, por lo que el tiempo de ejecución alternativo se guardó durante la última prueba de LLM.", + "Manual override from Settings. Save Draft to persist your edit.": "Anulación manual desde Configuración. Guarda el borrador para conservar tu edición.", + "Run the LLM test to auto-fill this field, or enter a value manually.": "Ejecuta la prueba LLM para completar automáticamente este campo o introduce un valor manualmente.", + "Dimension": "Dimensión", + "Send dimensions": "Enviar dimensiones", + "Some embedding models (e.g. Qwen text-embedding-v4) reject the `dimensions` request param. Turn this off if your provider returns HTTP 400.": "Algunos modelos de embedding (por ejemplo, Qwen text-embedding-v4) rechazan el parámetro de solicitud \"dimensiones\". Desactívelo si tu proveedor devuelve HTTP 400.", + "Source: detected from API probe": "Fuente: detectado a partir de la sonda API", + "Detected": "Detectado", + "Use this": "Usa esto", + "Auto (probe on next test)": "Auto (sonda en la próxima prueba)", + "Custom…": "Personalizado…", + "Use a supported value": "Usa un valor admitido", + "No profiles configured. Add a profile to start.": "No hay perfiles configurados. Añade un perfil para empezar.", + "Diagnostics": "Diagnóstico", + "Run test": "Ejecutar prueba", + "Collapse diagnostics": "Colapso de diagnóstico", + "Expand diagnostics": "Ampliar diagnóstico", + "Streams config snapshot, request target, response summary, and service-specific validation for the active {{service}} profile.": "Instantánea de configuración de transmisiones, destino de solicitud, resumen de respuesta y validación específica del servicio para el perfil {{service}} activo.", + "Run Terminal Tour": "Ejecutar recorrido por la terminal", + "Loading settings...": "Cargando configuración...", + "words": "palabras", + "chars": "caracteres", + "Export Markdown": "Exportar Markdown", + "Load Example Template": "Cargar plantilla de ejemplo", + "Pro Vide Writing": "Escritura de vídeo profesional", + "Full Draft": "Borrador completo", + "Editor": "Editor", + "Collapse editor": "Contraer editor", + "Start writing in Markdown...": "Empieza a escribir en Markdown...", + "Expand editor": "Expandir editor", + "Expand preview": "Ampliar vista previa", + "Collapse preview": "Contraer vista previa", + "Resize editor and preview": "Editor de cambio de tamaño y vista previa", + "Drag to resize, double-click to reset": "Arrastra para cambiar el tamaño, haz doble clic para restablecer", + "Sync Scroll": "Desplazamiento de sincronización", + "Scroll sync is on. Click to disable.": "La sincronización de desplazamiento está activada. Haz clic para desactivar.", + "Scroll sync is off. Click to enable.": "La sincronización de desplazamiento está desactivada. Haz clic para habilitar.", + "Nothing to preview yet.": "No hay nada que previsualizar todavía.", + "Tell AI what to do with the selection...": "Dile a AI qué hacer con la selección...", + "Apply AI edit": "Aplicar edición de IA", + "{{count}} tools": "{{count}} herramientas", + "Mode": "Modo", + "Trace": "Traza", + "Thought": "Pensamiento", + "Thinking...": "Pensando...", + "Model thinking": "Razonamiento del modelo", + "Tool": "Herramienta", + "Response": "Respuesta", + "Running tools and preparing the final edit...": "Ejecutando herramientas y preparando la edición final...", + "Full Draft AI Edit": "Borrador completo de edición de IA", + "Describe how you want the text edited...": "Describe cómo quieres que se edite el texto...", + "Source": "Fuente", + "None": "Ninguno", + "Select...": "Seleccionar...", + "Auto Mark": "Marca automática", + "Example template is already loaded.": "La plantilla de ejemplo ya está cargada.", + "Loaded example template.": "Plantilla de ejemplo cargada.", + "Draft is already empty.": "El borrador ya está vacío.", + "Draft cleared. Press Ctrl/Cmd+Z or use Undo to restore it.": "Borrador aprobado. Presione Ctrl/Cmd+Z o use Deshacer para restaurarlo.", + "Loaded example template. Press Ctrl/Cmd+Z or use Undo to restore it.": "Plantilla de ejemplo cargada. Presione Ctrl/Cmd+Z o use Deshacer para restaurarlo.", + "Clear this draft?": "¿Borrar este borrador?", + "This will empty the editor. The previous content is kept in Undo until you leave this draft.": "Esto vaciará el editor. El contenido anterior se mantiene en Deshacer hasta que abandones este borrador.", + "Clear draft": "Borrar borrador", + "Replace with the example template?": "¿Reemplazar con la plantilla de ejemplo?", + "This will replace the current editor content. The previous content is kept in Undo until you leave this draft.": "Esto reemplazará el contenido del editor actual. El contenido anterior se mantiene en Deshacer hasta que abandones este borrador.", + "Load template": "Cargar plantilla", + "Undo is available with Ctrl/Cmd+Z or the toolbar Undo button.": "Deshacer está disponible con Ctrl/Cmd+Z o el botón Deshacer de la barra de herramientas.", + "Applied AI edit to the selection.": "Edición de IA aplicada a la selección.", + "Applied {{action}} to the full draft.": "Se aplicó {{action}} al borrador completo.", + "Applied auto-mark annotations.": "Anotaciones de marca automática aplicadas.", + "Please select a text passage first.": "Selecciona primero un pasaje de texto.", + "Please enter an instruction or choose a mode.": "Introduce una instrucción o elige un modo.", + "Please enter an editing instruction first.": "Primero introduce una instrucción de edición.", + "Co-Writer Draft": "Borrador del coguionista", + "Undo": "Deshacer", + "Redo": "Rehacer", + "Heading 1": "Título 1", + "Heading 2": "Título 2", + "Heading 3": "Título 3", + "Heading 4": "Título 4", + "Heading 5": "Título 5", + "Heading 6": "Título 6", + "Bold": "Atrevido", + "Italic": "Itálico", + "Strikethrough": "Tachado", + "Inline Code": "Código en línea", + "Blockquote": "cita en bloque", + "Bullet List": "Lista de viñetas", + "Numbered List": "Lista numerada", + "Task List": "Lista de tareas", + "Horizontal Rule": "Regla horizontal", + "Table": "Mesa", + "Link": "Enlace", + "Image": "Imagen", + "Code Block": "Bloque de código", + "Mermaid Diagram": "Diagrama de sirena", + "Math Block": "Bloque matemático", + "Bots": "robots", + "Profiles": "Perfiles", + "Souls": "almas", + "New Bot": "Nuevo robot", + "(optional)": "(opcional)", + "e.g. Math Tutor": "p.ej. Tutor de Matemáticas", + "A brief description of what this bot does": "Una breve descripción de lo que hace este bot.", + "Soul": "Alma", + "Define the bot's personality, values, and communication style in markdown...": "Defina la personalidad, los valores y el estilo de comunicación del bot en Markdown...", + "Pick a soul from the library above, or write your own. Manage the library in the Souls tab.": "Elige un alma de la biblioteca de arriba o escribe la tuya propia. Administra la biblioteca en la pestaña Almas.", + "Uses default model if empty": "Utiliza el modelo predeterminado si está vacío", + "Choose a configured Settings model, or keep the system default.": "Elige un modelo de configuración configurado o mantén el sistema predeterminado.", + "Model saved": "Modelo guardado", + "Failed to save model": "No se pudo guardar el modelo", + "Selected model": "Modelo seleccionado", + "Create & Start": "Crear y comenzar", + "Stop": "Detener", + "Stop generating": "Detener generación", + "Answer now": "Responde ahora", + "Answering…": "Respondiendo…", + "Skip reasoning and answer now": "Salta el razonamiento y responde ahora.", + "Permanently delete \"{{name}}\" ({{id}})? This cannot be undone.": "¿Eliminar permanentemente \"{{name}}\" ({{id}})? Esto no se puede deshacer.", + "started {{time}}": "iniciado {{time}}", + "No bots to configure": "No hay bots para configurar", + "Create a bot first in the Bots tab.": "Primero crea un bot en la pestaña Bots.", + "Bot": "Bot", + "Edit {{file}}...": "Editar {{file}}...", + "Unsaved changes": "Cambios no guardados", + "{{file}} is empty": "{{file}} está vacío", + "Switch to Edit to add content.": "Cambia a Editar para añadir contenido.", + "New Soul": "Nueva alma", + "Content": "Contenido", + "e.g. Creative Writer": "p.ej. Escritor creativo", + "Define the soul in markdown...": "Define la personalidad en Markdown...", + "No souls yet": "Aún no hay almas", + "Create your first soul template. Default presets will be seeded automatically on next server restart.": "Crea tu primera plantilla de alma. Los ajustes preestablecidos predeterminados se generarán automáticamente en el próximo reinicio del servidor.", + "Delete soul \"{{name}}\"?": "¿Eliminar el alma \"{{name}}\"?", + "Send a message to start the conversation.": "Envía un mensaje para iniciar la conversación.", + "Chat with {{name}}": "Chatea con {{name}}", + "Type a message...": "Escribe un mensaje...", + "Tips": "Consejos", + "Type / to see commands — start a new conversation, view history, toggle tools, and more.": "Escribe / para ver comandos: inicia una nueva conversación, vea el historial, alterne herramientas y más.", + "Attach images or documents with the clip, or just drag them in.": "Adjunte imágenes o documentos con el clip, o simplemente arrástrelos.", + "Thinking ({{count}} steps)": "Pensando ({{count}} pasos)", + "Working...": "Trabajando...", + "Playground": "Patio de juegos", + "Explore the building blocks of DeepTutor: reusable tools and higher-level capabilities.": "Explore los componentes básicos de DeepTutor: herramientas reutilizables y capacidades de nivel superior.", + "Capabilities": "Capacidades", + "Capability": "Capacidad", + "Enable Tools": "Habilitar herramientas", + "This capability runs without optional tools.": "Esta capacidad se ejecuta sin herramientas opcionales.", + "Try this capability": "Prueba esta capacidad", + "Run a focused conversation here without leaving the playground.": "Mantén una conversación enfocada aquí sin salir del patio de recreo.", + "Select knowledge base...": "Seleccionar base de conocimientos...", + "Code input": "Entrada de código", + "Query input": "Entrada de consulta", + "Enter your input...": "Introduce tu entrada...", + "Enter your query...": "Introduce tu consulta...", + "Running...": "Ejecutando...", + "Execute": "Ejecutar", + "Details": "Detalles", + "Tool call": "llamada de herramienta", + "Tool result": "Resultado de la herramienta", + "Auto": "Auto", + "Coding": "Codificación", + "Preference": "Preferencia", + "Max Questions": "Preguntas máximas", + "Or": "O", + "You": "Tú", + "Generate": "Generar", + "Selected sources will be queried during research.": "Las fuentes seleccionadas serán consultadas durante la investigación.", + "No source selected: the run will use llm-only research.": "No hay fuentes seleccionadas: la investigación utilizará únicamente el LLM.", + "Describe the research topic...": "Describe el tema de investigación...", + "Run Research": "Ejecutar investigación", + "Code Execution": "Ejecución de código", + "default": "por defecto", + "Extra constraints, style, focus areas...": "Restricciones adicionales, estilo, áreas de enfoque...", + "Try": "Intentar", + "No conversations yet": "Aún no hay conversaciones", + "New conversation": "Nueva conversación", + "Add files & context": "Añadir archivos y contexto", + "Select persona": "Seleccionar perfil", + "Record voice": "Grabar voz", + "Archived": "Archivado", + "Archived conversation": "Conversación archivada", + "Continue this conversation": "Continuar esta conversación", + "Delete conversation": "Eliminar conversación", + "Conversation deleted": "Conversación eliminada", + "Session not found": "Sesión no encontrada", + "Conversations:": "Conversaciones:", + "Nothing to branch yet.": "Todavía no se puede crear una rama.", + "Branched — the original is archived as {{id}}": "Ramificado: el original se archiva como {{id}}", + "Usage: /resume ": "Uso: /resume ", + "Usage: /delete ": "Uso: /eliminar ", + "Use /resume or /delete .": "Usa /reanudar o /eliminar .", + "Save title": "Guardar título", + "Rename chat": "Cambiar el nombre del chat", + "Delete chat": "Eliminar chat", + "Notebook": "Cuaderno", + "Retrieve": "Recuperar", + "Context exploration": "Exploración del contexto", + "Exploring your context…": "Explorando tu contexto...", + "Plan": "Plan", + "Observe": "Observar", + "Rendering diagram...": "Diagrama de renderizado...", + "Process Logs": "Registros de proceso", + "Raw logs": "Troncos crudos", + "Notebook Output": "Salida del cuaderno", + "Select one or more notebooks. A summary will be generated automatically.": "Selecciona uno o más cuadernos. Se generará un resumen automáticamente.", + "Title": "Título", + "No notebooks found.": "No se encontraron cuadernos.", + "Summary preview": "Vista previa del resumen", + "The generated summary will appear here during saving.": "El resumen generado aparecerá aquí durante el guardado.", + "Choose which messages to include, pick one or more notebooks, and a summary will be generated automatically.": "Elige qué mensajes incluir, elige uno o más cuadernos y se generará un resumen automáticamente.", + "Messages to include": "Mensajes para incluir", + "{{selected}} of {{total}} selected": "{{selected}} de {{total}} seleccionado", + "Select all": "Seleccionar todo", + "Last turn": "último turno", + "Last 3 turns": "últimos 3 turnos", + "(empty message)": "(mensaje vacío)", + "System": "Sistema", + "Select Notebook Records": "Seleccionar registros del cuaderno", + "Choose records across one or more notebooks to ground the next request.": "Elige registros en uno o más cuadernos para basar la siguiente solicitud.", + "Notebook Reference": "Referencia del cuaderno", + "Chat History Reference": "Referencia del historial de chat", + "Select History Sessions": "Seleccionar sesiones de historia", + "Choose one or more past conversations to analyze before this turn.": "Elige una o más conversaciones pasadas para analizar antes de este turno.", + "No messages in this session.": "No hay mensajes en esta sesión.", + "Select a session to preview it here.": "Selecciona una sesión para obtener una vista previa aquí.", + "Search sessions by title or last message": "Buscar sesiones por título o último mensaje", + "No matching sessions found.": "No se encontraron sesiones coincidentes.", + "Untitled session": "Sesión sin título", + "1 session selected": "1 sesión seleccionada", + "{n} sessions selected": "{n} sesiones seleccionadas", + "Use Selected Sessions ({n})": "Usar sesiones seleccionadas ({n})", + "Use Selected Records ({n})": "Usar registros seleccionados ({n})", + "selected": "seleccionado", + "Save a chat result first to populate a notebook.": "Guarda primero el resultado del chat para completar un cuaderno.", + "Create one from the Knowledge → Notebooks page.": "Crea uno desde la página Conocimiento → Cuadernos.", + "Delete notebook \"{{name}}\"?": "¿Eliminar el cuaderno \"{{name}}\"?", + "Deselect": "Deseleccionar", + "No records": "Sin registros", + "Ground the plan with saved notebook records.": "Base el plan con registros de cuaderno guardados.", + "Select records": "Seleccionar registros", + "Update selection ({n})": "Actualizar selección ({n})", + "Follow-up Chat": "Chat de seguimiento", + "Ask anything about this question": "Pregunta cualquier cosa sobre esta pregunta.", + "Try: why this answer is correct, where your reasoning went wrong, or ask for a cleaner explanation.": "Prueba: por qué esta respuesta es correcta, dónde salió mal tu razonamiento o solicite una explicación más clara.", + "Ask a follow-up question about this item...": "Haz una pregunta de seguimiento sobre este elemento...", + "Mimic Paper": "Simular examen", + "Upload PDF": "Subir PDF", + "Extra constraints...": "Restricciones adicionales...", + "Check Answer": "Verificar respuesta", + "Correct": "Correcto", + "Incorrect": "Incorrecto", + "Retry": "Rever", + "Reference Answer": "Respuesta de referencia", + "Previous": "Anterior", + "Write your code here...": "Escribe tu código aquí...", + "Type your answer...": "Escribe tu respuesta...", + "Research Outline": "Esquema de la investigación", + "Add sub-topic": "Añadir subtema", + "Sub-topic title...": "Título del subtema...", + "Research direction and focus...": "Dirección y enfoque de la investigación...", + "Video": "Video", + "Low": "Bajo", + "High": "Alto", + "Reasoning effort": "Nivel de razonamiento", + "Provider default (Auto)": "Valor predeterminado del proveedor (automático)", + "Minimal": "Mínimo", + "Extra high": "Muy alto", + "Adaptive": "Adaptativo", + "Sets this model's default reasoning depth. Auto leaves the choice to the provider.": "Establece el nivel de razonamiento predeterminado de este modelo. En automático, la elección queda en manos del proveedor.", + "Style, pacing, color...": "Estilo, ritmo, color...", + "Fullscreen": "Pantalla completa", + "View Manim Code": "Ver Código Manim", + "Video Output": "Salida de vídeo", + "Image Output": "Salida de imagen", + "Fullscreen math animation output": "Salida de animación matemática en pantalla completa", + "Delete this chat history?": "¿Eliminar este historial de chat?", + "Question Bank": "banco de preguntas", + "Question Bank Reference": "Referencia del banco de preguntas", + "Select Question Bank Entries": "Seleccionar entradas del banco de preguntas", + "Choose quiz questions to ground the next request.": "Elige preguntas del cuestionario para fundamentar la siguiente solicitud.", + "Use Selected Questions ({n})": "Usar preguntas seleccionadas ({n})", + "Search questions by content": "Buscar preguntas por contenido", + "1 question selected": "1 pregunta seleccionada", + "{n} questions selected": "{n} preguntas seleccionadas", + "No matching questions found.": "No se encontraron preguntas coincidentes.", + "No quiz entries yet.": "Aún no hay entradas para el cuestionario.", + "Review and organize quiz questions across sessions.": "Revisa y organice las preguntas del cuestionario en todas las sesiones.", + "All": "Todo", + "Bookmarked": "Marcado como favorito", + "Wrong Only": "Solo incorrectas", + "Total": "Total", + "Categories": "Categorías", + "Manage Categories": "Administrar categorías", + "New category name...": "Nuevo nombre de categoría...", + "New category...": "Nueva categoría...", + "Delete this category?": "¿Eliminar esta categoría?", + "Remove from category": "Eliminar de la categoría", + "No entries yet": "Aún no hay entradas", + "Questions from your quizzes will appear here.": "Las preguntas de tus cuestionarios aparecerán aquí.", + "Your Answer": "Tu respuesta", + "From session": "De la sesión", + "Follow-up": "Seguimiento", + "Bookmark": "Marcador", + "Remove Bookmark": "Quitar marcador", + "Add to Category": "Añadir a la categoría", + "Delete this entry?": "¿Eliminar esta entrada?", + "Failed to load entries": "No se pudieron cargar las entradas", + "Your pick": "Tu elección", + "No categories yet.": "Aún no hay categorías.", + "Original Session": "Sesión original", + "Visualize": "Visualizar", + "Generate SVG, Chart.js, or Mermaid visualizations": "Genera visualizaciones SVG, Chart.js o Mermaid", + "Render Mode": "Modo de renderizado", + "Chart.js": "Gráfico.js", + "SVG": "SVG", + "Mermaid": "Sirena", + "Reference": "Referencia", + "Describe the chart or diagram you want to visualize...": "Describe el cuadro o diagrama que deseas visualizar...", + "Show code": "Mostrar código", + "Hide code": "Ocultar código", + "Copy code": "Copiar código", + "Copied": "copiado", + "Review": "Revisar", + "Failed to render chart": "No se pudo representar el gráfico", + "Chart rendering error": "Error de representación del gráfico", + "SVG rendering error": "Error de representación SVG", + "Invalid SVG: does not start with =20 GB of free disk (official guidance): ~2-4 GB install, ~1-2 GB models lazily downloaded on first parse, plus parsing cache.": "El proceso del servidor DeepTutor debe poder acceder a la CLI de MinerU. Reserva >=20 GB de disco libre (orientación oficial): ~2-4 GB de instalación, ~1-2 GB de modelos descargados de forma diferida en el primer análisis, además de caché de análisis.", + "Configured CLI path is not executable.": "La ruta CLI configurada no es ejecutable.", + "Install into any Python environment: uv pip install -U \"mineru[core]\" — then set the CLI path below, or install into the server environment for PATH auto-detection.": "Instálelo en cualquier entorno Python: uv pip install -U \"mineru[core]\"; luego configura la ruta CLI a continuación o instálelo en el entorno del servidor para la detección automática de RUTA.", + "CLI path": "Ruta CLI", + "Optional. Point to a mineru executable in an isolated environment (uv tool, pipx, conda) to avoid dependency conflicts. Leave blank to auto-detect from PATH.": "Opcional. Indica a un ejecutable de mineru en un entorno aislado (herramienta uv, pipx, conda) para evitar conflictos de dependencia. Déjelo en blanco para detectar automáticamente desde PATH.", + "Auto-detect from PATH": "Detectar automáticamente desde PATH", + "Model weights": "Pesos del modelo", + "Local parsing needs ~1-2 GB of model weights. They download lazily on the first parse, or download them ahead of time here.": "El análisis local necesita entre 1 y 2 GB de pesos de modelo. Se descargan lentamente en el primer análisis o se descargan con anticipación aquí.", + "Download source": "Descargar fuente", + "ModelScope is usually faster in mainland China.": "ModelScope suele ser más rápido en China continental.", + "Download address": "Descargar dirección", + "Custom HuggingFace endpoint or mirror (e.g. a regional mirror). Leave blank for the official address.": "Endpoint o espejo personalizado de HuggingFace (por ejemplo, un espejo regional). Deja en blanco la dirección oficial.", + "Download models": "Descargar modelos", + "Runs mineru-models-download on the server using the source and address above.": "Ejecuta mineru-models-download en el servidor usando la fuente y la dirección anteriores.", + "Downloading models...": "Descargando modelos...", + "Download failed.": "La descarga falló.", + "The turn was interrupted.": "El turno fue interrumpido.", + "Running command": "Comando en ejecución", + "Running code": "Código en ejecución", + "Searching knowledge": "Buscando conocimiento", + "Listing knowledge base files": "Listando archivos de la base de conocimiento", + "Searching the web": "Buscando en la web", + "Searching papers": "Buscando artículos", + "Fetching page": "Recuperando página", + "Reading skill": "Consultando una habilidad", + "Loading tools": "Cargando herramientas", + "Reading source": "Consultando una fuente", + "Reading file": "Leyendo un archivo", + "Writing file": "Escribiendo un archivo", + "Editing file": "Editando un archivo", + "Listing files": "Listando archivos", + "Writing note": "Escribiendo una nota", + "Recalling memory": "Consultando la memoria", + "Saving memory": "Guardando en memoria", + "Brainstorming": "Lluvia de ideas", + "Asking you": "preguntándote", + "Querying GitHub": "Consultando GitHub", + "Analyzing figure": "Analizando figura", + "Visualizing": "Generando visualización", + "Animating": "Animando", + "Resize viewer": "Cambiar tamaño del visor", + "Partners": "Compañeros", + "Partners tooltip": "Información sobre compañeros", + "Companions with their own soul, library, and channels — reachable from your IM apps.": "Compañeros con su propia alma, biblioteca y canales, accesibles desde tus aplicaciones de mensajería.", + "New partner": "Nuevo compañero", + "No partners yet": "Aún no hay compañeros", + "Create a partner, give it a soul and a slice of your library, then talk to it here or from Feishu, Telegram, Slack and more.": "Crea un compañero, dale un alma y una porción de tu biblioteca, luego habla con él aquí o desde Feishu, Telegram, Slack y más.", + "Create your first partner": "Crea tu primer compañero", + "No channels connected": "No hay canales conectados", + "Partners your administrator has assigned to you. Click one to start chatting with it.": "Compañeros que tu administrador le ha asignado. Haz clic en uno para comenzar a chatear con él.", + "No partners assigned yet": "Aún no hay compañeros asignados", + "Your administrator hasn't assigned you any partners yet. Once they do, they'll appear here.": "tu administrador aún no le ha asignado ningún compañero. Una vez que lo hagan, aparecerán aquí.", + "A companion that lives on your channels and works from its own library.": "Un compañero que vive de tus canales y trabaja desde tu propia biblioteca.", + "Identity": "Identidad", + "e.g. Ada": "p.ej. ada", + "Reply language": "Idioma de respuesta", + "Auto (English)": "Automático (inglés)", + "What is this partner for?": "¿Para qué es este compañero?", + "Face": "Rostro", + "Who this partner is. Start from a template, clone a chat persona, or write it yourself.": "Quién es este compañero. Comienza a partir de una plantilla, clona un personaje de chat o escríbelo tú mismo.", + "Mind": "Mente", + "The model it thinks with and the tools it may use.": "El modelo con el que piensa y las herramientas que puede utilizar.", + "Library": "Biblioteca", + "Give it a slice of your knowledge — copied into the partner's own workspace.": "Dale una parte de tus conocimientos, copiada en el espacio de trabajo del propio compañero.", + "You can connect Feishu, Telegram, Slack and more right after creating.": "Puedes conectar Feishu, Telegram, Slack y más inmediatamente después de crear.", + "Create partner": "Crear compañero", + "Create failed": "Error al crear", + "Soul library": "biblioteca del alma", + "Clone a persona": "Clonar una persona", + "Write your own": "Escribe el tuyo", + "No soul templates yet.": "Aún no hay plantillas de alma.", + "No personas in your chat workspace yet.": "Aún no hay personas en tu espacio de chat.", + "The persona's markdown is copied into the partner — later edits to the persona won't affect it.": "El contenido Markdown de la persona se copia en el compañero; los cambios posteriores en la persona no le afectarán.", + "# Soul\nDescribe who this partner is, how it speaks, what it values…": "# alma\nDescribe quién es este compañero, cómo habla, qué valora…", + "Loading tools…": "Cargando herramientas…", + "System tools": "Herramientas del sistema", + "Built-in tools": "Herramientas integradas", + "Mounted automatically when the context calls for it — a knowledge base attached, memory available, the sandbox enabled. Deny any you don't want this partner to have.": "Se activa automáticamente cuando el contexto lo requiere: hay una base de conocimiento adjunta, memoria disponible o un entorno aislado habilitado. Deniega las herramientas que no quieras que use este compañero.", + "MCP tools": "herramientas MCP", + "Loading your library…": "Cargando tu biblioteca…", + "No knowledge bases available.": "No hay bases de conocimiento disponibles.", + "No skills available.": "No hay habilidades disponibles.", + "No notebooks available.": "No hay cuadernos disponibles.", + "Selected items are copied into the partner's private workspace.": "Los elementos seleccionados se copian en el espacio de trabajo privado del compañero.", + "Say hello — this conversation shares the same memory your partner has on its connected channels.": "Saluda: esta conversación comparte la misma memoria que tu compañero utiliza en sus canales conectados.", + "Partner is stopped. Start it before chatting.": "El compañero está detenido. Inícielo antes de chatear.", + "Connecting…": "Conectando…", + "Back to Partners": "Volver a Compañeros", + "Partner not found": "Compañero no encontrado", + "Partner stopped": "Compañero detenido", + "Partner started": "Compañero iniciado", + "Action failed": "La acción falló", + "Delete this partner and ALL its data (workspace, sessions, channels)? This cannot be undone.": "¿Eliminar a este compañero y TODOS sus datos (espacio de trabajo, sesiones, canales)? Esto no se puede deshacer.", + "Delete failed": "Error al eliminar", + "Delete partner": "Eliminar compañero", + "Configure": "Configurar", + "Soul saved": "Soul guardada", + "Model updated — applies from the next message": "Modelo actualizado: se aplica a partir del siguiente mensaje.", + "Tools updated — applies from the next message": "Herramientas actualizadas: se aplica a partir del siguiente mensaje.", + "Some items failed: {{names}}": "Se ha producido un error en estos elementos: {{names}}", + "Assets added": "Recursos añadidos", + "Remove failed": "Error al eliminar", + "Skill": "Habilidad", + "The partner's persona — injected into every conversation.": "La personalidad del compañero se incorpora a todas sus conversaciones.", + "Applies to this partner only; falls back to the system default.": "Se aplica únicamente a este compañero; vuelve al valor predeterminado del sistema.", + "What this partner is allowed to use.": "Lo que este compañero puede usar.", + "Knowledge bases, skills, and notebooks copied into this partner's workspace.": "Bases de conocimientos, habilidades y cuadernos copiados en el espacio de trabajo de este compañero.", + "Copy into workspace": "Copiar al espacio de trabajo", + "Nothing assigned yet — this partner only knows what you tell it.": "Aún no tiene nada asignado: este compañero solo sabe lo que tú le dices.", + "Config is saved on disk; stop and start the partner to apply.": "La configuración se guarda en el disco; detener y comenzar a aplicar el compañero.", + "Couldn't render this document — use Download to open it.": "No se pudo procesar este documento; use Descargar para abrirlo.", + "Couldn't render this spreadsheet — use Download to open it.": "No se pudo procesar esta hoja de cálculo; use Descargar para abrirla.", + "This workbook has no sheets to preview.": "Este libro no tiene hojas para obtener una vista previa.", + "Large sheet — preview truncated. Download for the full file.": "Hoja grande: vista previa truncada. Descargue el archivo completo.", + "Session activity, attachments & previews": "Actividad de la sesión, archivos adjuntos y vistas previas", + "Loading models…": "Cargando modelos…", + "Could not load the model catalog — the partner will use the system default.": "No se pudo cargar el catálogo de modelos: el compañero utilizará el sistema predeterminado.", + "Follows whatever the system default model is.": "Sigue cualquiera que sea el modelo predeterminado del sistema.", + "No models configured yet — add providers in Settings → LLM.": "Aún no hay modelos configurados: añade proveedores en Configuración → LLM.", + "Save to soul library": "Guardar en la biblioteca del alma", + "Who is this partner?": "¿Quién es este compañero?", + "A name and a face — everything else can change later.": "Un nombre y una cara; todo lo demás puede cambiar más adelante.", + "Give it a soul": "Dale un alma", + "Shape its mind": "Dale forma a su mente", + "Hand over some knowledge": "Entregar algunos conocimientos", + "Ready to meet {{name}}?": "¿Listo para conocer a {{name}}?", + "your partner": "tu compañero", + "Almost there — check the essentials before creating.": "Ya casi hemos llegado: comprueba lo esencial antes de crear.", + "Default soul": "Alma predeterminada", + "{{count}} items will be copied": "Se copiarán {{count}} elementos", + "Primary model": "Modelo principal", + "Backup model": "Modelo de respaldo", + "No backup": "Sin respaldo", + "Failed turns are not retried.": "Los giros fallidos no se reintentan.", + "The primary model answers every turn; if it fails outright, the turn is retried once on the backup.": "El modelo principal responde a cada paso; si falla por completo, el turno se vuelve a intentar una vez en la copia de seguridad.", + "{{name}} Reasoning…": "{{name}} está razonando…", + "{{name}} Planning…": "{{name}} está planificando…", + "{{name}} Drafting…": "{{name}} está redactando…", + "{{name}} Responding…": "{{name}} está respondiendo…", + "{{name}} Exploring…": "{{name}} está explorando…", + "{{name}} Quizzing…": "{{name}} está creando preguntas…", + "{{name}} Reflecting…": "{{name}} está reflexionando…", + "Add some content before running auto-mark.": "Añade contenido antes de ejecutar la marca automática.", + "The draft changed while AI edit was running. Please try again.": "El borrador cambió mientras se ejecutaba la edición AI. Por favor inténtalo de nuevo.", + "Coming soon": "Muy pronto", + "Mastery Path": "Ruta de aprendizaje", + "This feature is being reworked and will be back soon.": "Esta función se está modificando y volverá pronto.", + "Replace photo": "Reemplazar foto", + "Upload photo or SVG": "Subir foto o SVG", + "Use a PNG, JPG, WebP, GIF, or SVG image.": "Usa una imagen PNG, JPG, WebP, GIF o SVG.", + "That file is too large.": "Ese archivo es demasiado grande.", + "Could not read that image.": "No se pudo leer esa imagen.", + "Please use the attached file(s).": "Usa los archivos adjuntos.", + "This channel is not available on the server.": "Este canal no está disponible en el servidor.", + "Partner": "Compañero", + "Edited — your version becomes this partner's soul. The original template is untouched.": "Editado: tu versión se convierte en el alma de este compañero. La plantilla original está intacta.", + "Mastery-based tutoring with a hard gate": "Tutoría basada en el dominio con una puerta dura", + "Mastery Path tooltip": "Aprendizaje basado en el dominio con una puerta dura", + "Index engine": "motor de índice", + "Needs key": "Necesita clave", + "This engine needs an API key. Configure it before creating.": "Este motor necesita una clave API. Configúrala antes de crear la base.", + "PDF, Office, text and Markdown": "PDF, Office, texto y Markdown", + "PageIndex settings": "Configuración de PageIndex", + "Get an API key": "Obtenga una clave API", + "PageIndex is a hosted, vectorless retrieval engine. Documents in a PageIndex knowledge base are uploaded to PageIndex's servers for processing. One key is shared by all your PageIndex knowledge bases.": "PageIndex es un motor de recuperación alojado que no utiliza vectores. Los documentos de sus bases de conocimiento se suben a los servidores de PageIndex para procesarlos. Todas tus bases de conocimiento de PageIndex comparten una clave.", + "API key": "clave API", + "•••••••• (configured — leave blank to keep)": "•••••••• (configurado: déjelo en blanco para conservarlo)", + "Enter your PageIndex API key": "Introduce tu clave API de PageIndex", + "Remove stored key": "Eliminar clave almacenada", + "Manage your knowledge bases and retrieval engines.": "Gestiona tus bases de conocimiento y motores de recuperación.", + "Retrieval engines": "Motores de recuperación", + "Configure key": "Configurar clave", + "Manage key": "Administrar clave", + "Obsidian": "Obsidian", + "Connect vault": "Conectar bóveda", + "Connect": "Conectar", + "Connect Obsidian vault": "Conectar bóveda de Obsidian", + "{{count}} connected": "{{count}} conectado", + "Vault path": "Ruta de la bóveda", + "e.g. My Vault": "p.ej. mi bóveda", + "Connect your Obsidian vault. The tutor navigates and writes notes in place — no upload, no index. Local / self-hosted only.": "Conecta tu bóveda de Obsidian. El tutor navega y escribe notas en su ubicación original, sin subirlas ni indexarlas. Solo para instalaciones locales o autoalojadas.", + "Point DeepTutor at a folder that is your Obsidian vault. Nothing is uploaded or copied — DeepTutor reads (and, when you ask, writes) the notes in place. The path must be reachable by the server, so this works on a local / self-hosted DeepTutor.": "Indica la carpeta de tu bóveda de Obsidian. No se sube ni se copia nada: DeepTutor lee y, cuando se lo pides, escribe las notas en su ubicación original. El servidor debe poder acceder a la ruta, por lo que esta función requiere una instalación local o autoalojada.", + "The absolute path to the vault folder on this machine.": "La ruta absoluta a la carpeta del almacén en esta máquina.", + "Create new": "Crear nuevo", + "Link existing": "Vincular existente", + "Upload documents and build a fresh index.": "Cargue documentos y crea un índice nuevo.", + "Reuse an index you already built — read in place, no upload or re-index.": "Reutilice un índice que ya haya creado: léalo en el lugar, sin cargarlo ni volver a indexarlo.", + "This engine's index lives in the cloud and can't be linked.": "El índice de este motor se encuentra en la nube y no se puede vincular.", + "Cloud index": "Índice en la nube", + "A live Obsidian vault — browsed and edited in place, no index.": "Una bóveda de obsidiana viva: explorada y editada en el lugar, sin índice.", + "Folder path": "Ruta de la carpeta", + "The absolute path to a knowledge base folder on this machine — nothing is copied.": "La ruta absoluta a una carpeta de la base de conocimientos en esta máquina: no se copia nada.", + "Check folder": "Comprobar carpeta", + "This folder can't be linked": "Esta carpeta no se puede vincular", + "Ready index found": "Índice listo encontrado", + "{{count}} documents": "{{count}} documentos", + "Embedding model matches": "El modelo de embeddings coincide", + "Create one to upload documents and retrieve grounded context in chat.": "Crea uno para cargar documentos y recuperar contexto fundamentado en el chat.", + "docs": "documentos", + "Exit fullscreen": "Salir de pantalla completa", + "Move to…": "Mover a…", + "Move to": "Mover a", + "Root": "Raíz", + "No folders yet": "Aún no hay carpetas", + "New folder": "Nueva carpeta", + "Folder name": "Nombre de la carpeta", + "Or select an entire folder": "O selecciona una carpeta completa", + "Unsupported files are skipped; the rest will be indexed.": "Se omiten los archivos no compatibles; el resto será indexado.", + "{{ready}} ready, {{skip}} will be skipped": "{{ready}} listo, {{skip}} se omitirá", + "Allow automatic model download": "Permitir la descarga automática del modelo", + "Describe images with the vision model": "Describir imágenes con el modelo de visión.", + "Docling": "Docling", + "Docling isn't installed.": "Docling no está instalado.", + "Document Parsing": "Análisis de documentos", + "Engine": "Motor", + "Failed to load document parsing settings.": "No se pudo cargar la configuración de análisis del documento.", + "Failed to save document parsing settings.": "No se pudo guardar la configuración de análisis del documento.", + "Forwarded to MinerU for each document.": "Reenviado a MinerU para cada documento.", + "How uploaded documents are converted into text for knowledge bases and question generation. Pick an engine and its options. Local model downloads are off by default — they only happen when you explicitly allow them.": "Cómo los documentos cargados se convierten en texto para bases de conocimiento y generación de preguntas. Elige un motor y tus opciones. Las descargas de modelos locales están desactivadas de forma predeterminada; solo ocurren cuando las permites explícitamente.", + "Lightweight Markdown conversion with broad format support. No model downloads.": "Conversión Markdown ligera con amplio soporte de formato. No hay descargas de modelos.", + "Local parsing needs ~1-2 GB of model weights. By default they are NOT downloaded automatically — download them explicitly below, or allow automatic download on first parse.": "El análisis local necesita entre 1 y 2 GB de pesos de modelo. De forma predeterminada, NO se descargan automáticamente; descárguelos explícitamente a continuación o permita la descarga automática en el primer análisis.", + "Model status": "Estado del modelo", + "Not installed": "No instalado", + "Text-only": "Solo texto", + "Built-in plain text extraction for PDF, Office, and text files. No optional parser package, model download, OCR, or layout reconstruction.": "Extracción de texto sin formato incorporada para archivos PDF, Office y de texto. Sin paquete de análisis opcional, descarga de modelos, OCR ni reconstrucción de diseño.", + "Knowledge Center": "Centro de conocimiento", + "Retrieval profile": "Perfil de recuperación", + "Credentials": "Credenciales", + "Requirements": "Requisitos", + "Save changes": "Guardar cambios", + "Open catalog": "Abrir catálogo", + "Chunk size": "Tamaño del fragmento", + "Chunk overlap": "Superposición de fragmentos", + "Results per query": "Resultados por consulta", + "Vector candidate ×": "Candidato de vector ×", + "Keyword candidate ×": "Candidato de palabra clave ×", + "Used by hybrid only": "Usado solo por híbrido", + "Applies on the next re-index": "Aplica en la próxima reindexación", + "Retrieval & chunking": "Recuperación y fragmentación", + "No knowledge bases use this engine yet.": "Ninguna base de conocimiento utiliza este motor todavía.", + "This engine uses your active chat and embedding models. Manage them in the model catalog.": "Este motor utiliza tus modelos activos de chat y embeddings. Gestiónalos en el catálogo de modelos.", + "This engine ships as an optional dependency. Install it, then restart DeepTutor to enable it.": "Este motor se envía como una dependencia opcional. Instálelo y luego reinicie DeepTutor para habilitarlo.", + "The default for new searches. A knowledge base can still override it per-KB.": "El valor predeterminado para nuevas búsquedas. Una base de conocimientos aún puede anularla por KB.", + "BM25 keyword + vector semantic retrieval, fused and re-ranked. More robust recall.": "Palabra clave BM25 + recuperación semántica vectorial, fusionada y reclasificada. Recuerdo más sólido.", + "Vector semantic retrieval only. Faster, but leans entirely on embedding quality.": "Recuperación semántica vectorial únicamente. Más rápido, pero se basa exclusivamente en la calidad de la embedding.", + "Entity-focused retrieval over the relevant local subgraph — fast and economical.": "Recuperación centrada en la entidad a través del subgrafo local relevante: rápida y económica.", + "Map-reduce over global community summaries — best for broad, thematic questions.": "Reducción de mapas sobre resúmenes de la comunidad global: mejor para preguntas temáticas amplias.", + "Local retrieval with dynamic follow-ups — balances precision and coverage.": "Recuperación local con consultas de seguimiento dinámicas para equilibrar precisión y cobertura.", + "Plain vector retrieval — the lightest option.": "Recuperación de vectores simples: la opción más ligera.", + "Plain vector retrieval, without the knowledge graph.": "Recuperación de vectores simples, sin el gráfico de conocimiento.", + "Local context focused on the most relevant entities.": "Contexto local centrado en las entidades más relevantes.", + "Theme-level retrieval over global relationships.": "Recuperación a nivel de tema sobre relaciones globales.", + "Combines local and global retrieval — a solid general default.": "Combina recuperación local y global: un sólido valor predeterminado general.", + "Fuses knowledge-graph and vector retrieval.": "Fusiona gráficos de conocimiento y recuperación de vectores.", + "Retrieval parameters": "Parámetros de recuperación", + "Response style": "Estilo de respuesta", + "Community level": "nivel comunitario", + "Graph traversal granularity (local / drift)": "Granularidad transversal del gráfico (local/deriva)", + "Dynamic community selection": "Selección dinámica de comunidad", + "Global mode only": "Solo modo global", + "Chat model": "modelo de chat", + "Embedding model": "Modelo de embedding", + "No models configured — open catalog": "No hay modelos configurados — catálogo abierto", + "Multimodal documents need a vision-capable chat model.": "Los documentos multimodales necesitan un modelo de chat con capacidad visual.", + "Requirements & environment": "Requisitos y entorno", + "Check environment": "comprobar el entorno", + "Ready to use": "Preparado para usar", + "Not ready": "No listo", + "Active embedding model": "Modelo de embeddings activo", + "BM25 hybrid retrieval": "Recuperación híbrida BM25", + "API key configured": "Clave API configurada", + "GraphRAG package installed": "Paquete GraphRAG instalado", + "Active chat model": "Modelo de chat activo", + "RAG-Anything package installed": "Paquete RAG-Anything instalado", + "Vision model for multimodal": "Modelo de visión para multimodal.", + "Local vector engine — works out of the box. Retrieval uses your active embedding model; install the optional BM25 package to enable hybrid retrieval.": "Motor vectorial local listo para usar. La recuperación utiliza tu modelo activo de embeddings; instala el paquete opcional BM25 para activar la recuperación híbrida.", + "Hosted engine: documents are uploaded to PageIndex's servers and the chat agent reads them through the PageIndex MCP tools. Requires an API key; PDF, Office, text and Markdown formats.": "Motor alojado: los documentos se cargan en los servidores de PageIndex y el agente de chat los lee a través de las herramientas MCP de PageIndex. Requiere una clave API; Formatos PDF, Office, texto y Markdown.", + "Local knowledge-graph retrieval. Needs the optional dependency installed; indexing is LLM-heavy. Requires an active chat model and embedding model.": "Recuperación de gráficos de conocimiento local. Necesita tener instalada la dependencia opcional; la indexación tiene muchos LLM. Requiere un modelo de chat activo y un modelo de embedding.", + "Graph + vector retrieval with multimodal parsing. Needs the optional dependency installed; indexing is LLM-heavy. Requires active chat and embedding models; multimodal also needs a vision model.": "Recuperación de gráfico + vector con análisis multimodal. Necesita tener instalada la dependencia opcional; la indexación tiene muchos LLM. Requiere chat activo e embedding de modelos; multimodal también necesita un modelo de visión.", + "OCR scanned pages": "Páginas escaneadas con OCR", + "Off by default. When off, a local parse fails with guidance instead of silently downloading multi-GB weights on first run. Turn on to let the first parse fetch models, or use the explicit download below.": "Desactivado de forma predeterminada. Cuando está desactivado, un análisis local falla con orientación en lugar de descargar silenciosamente pesos de varios GB en la primera ejecución. Actívelo para permitir que el primer análisis obtenga modelos o usa la descarga explícita a continuación.", + "Off by default. When off, parsing fails with guidance instead of silently downloading models. Or pre-fetch with `docling-tools models download`.": "Desactivado de forma predeterminada. Cuando está desactivado, el análisis falla con orientación en lugar de descargar modelos silenciosamente. O realice una búsqueda previa con la \"descarga de modelos de herramientas de documentación\".", + "Ready to parse.": "Listo para analizar.", + "Recognize tables": "Reconocer tablas", + "Reserved — uses DeepTutor's vision model to caption images during conversion.": "Reservado: utiliza el modelo de visión de DeepTutor para subtitular imágenes durante la conversión.", + "Slower; enable for image-only PDFs.": "Más lento; habilitar para archivos PDF de solo imagen.", + "Structured conversion of PDF/Office/HTML/images. Downloads layout/table models on first run.": "Conversión estructurada de PDF/Office/HTML/imágenes. Descarga modelos de diseño/tabla en la primera ejecución.", + "The active engine handles all parsing. markitdown is lightweight and needs no model downloads; MinerU and Docling produce richer structure but download local models (gated below) or use a hosted API.": "El motor activo maneja todo el análisis. markitdown es liviano y no necesita descargas de modelos; MinerU y Docling producen una estructura más rica, pero descargan modelos locales (con acceso a continuación) o usan una API alojada.", + "The active engine handles all parsing. Text-only is built in and extracts plain text; markitdown is lightweight and optional; MinerU and Docling produce richer structure but may need local models or a hosted API.": "El motor activo maneja todo el análisis. La función de solo texto está integrada y extrae texto sin formato; Markitdown es liviano y opcional; MinerU y Docling producen una estructura más rica, pero pueden necesitar modelos locales o una API alojada.", + "markitdown": "MarkItDown", + "markitdown isn't installed.": "MarkItDown no está instalado.", + "Retrieval mode": "Modo de recuperación", + "Imported Chats": "Chats importados", + "Conversations imported from Claude Code and Codex.": "Conversaciones importadas de Claude Code y Codex.", + "Bring your Claude Code and Codex history into your space, then keep the conversation going.": "Importa tu historial de Claude Code y Codex a tu espacio y continúa las conversaciones.", + "Import": "Importar", + "Search imported chats...": "Buscar chats importados...", + "Delete this imported chat?": "¿Eliminar este chat importado?", + "No imported conversations yet": "Aún no hay conversaciones importadas", + "Pick your local .claude or .codex folder — DeepTutor finds each project and brings the chats in.": "Elige tu carpeta .claude o .codex local: DeepTutor encuentra cada proyecto e incorpora los chats.", + "Import conversations": "Importar conversaciones", + "Reading your folder…": "Leyendo tu carpeta...", + "Finding projects and conversations.": "Buscando proyectos y conversaciones.", + "No conversations found": "No se encontraron conversaciones", + "This folder has no readable chat history. Pick your .claude or .codex folder.": "Esta carpeta no tiene un historial de chat legible. Elige tu carpeta .claude o .codex.", + "Select your local .claude or .codex folder. DeepTutor reads it right here in your browser — nothing leaves your machine until you choose what to import.": "Selecciona tu carpeta local .claude o .codex. DeepTutor lo lee aquí mismo en tu navegador: nada sale de tu máquina hasta que elijas qué importar.", + "It auto-detects the tool, groups conversations by project, and lets you pick exactly what to bring in.": "Detecta automáticamente la herramienta, agrupa las conversaciones por proyecto y te permite elegir exactamente qué incluir.", + "Your browser doesn't support folder access. Please use a Chromium-based browser (Chrome, Edge, Arc).": "tu navegador no admite el acceso a carpetas. Usa un navegador basado en Chromium (Chrome, Edge, Arc).", + "Tip: in the folder picker, press ⌘⇧. (Mac) to reveal hidden folders like .claude.": "Consejo: en el selector de carpetas, presione ⌘⇧. (Mac) para revelar carpetas ocultas como .claude.", + "{{projects}} projects · {{sessions}} conversations": "{{projects}} proyectos · {{sessions}} conversaciones", + "Select project": "Seleccionar proyecto", + "{{count}} chats": "{{count}} chats", + "Select conversation": "Seleccionar conversación", + "Untitled conversation": "Conversación sin título", + "Reading conversations…": "Leyendo conversaciones...", + "Saving to your space…": "Guardando en tu espacio...", + "{{done}} / {{total}}": "{{done}} / {{total}}", + "Almost there.": "Casi llegamos.", + "Imported {{count}} conversations": "{{count}} conversaciones importadas", + "{{count}} skipped (already imported or empty).": "{{count}} omitido (ya importado o vacío).", + "They're ready in your space — open one to keep chatting.": "Están listos en tu espacio: abre uno para seguir chateando.", + "This folder doesn't look like a .claude or .codex home. Please select the right folder.": "Esta carpeta no parece una carpeta de inicio .claude o .codex. Selecciona la carpeta correcta.", + "Your browser doesn't support folder access. Please use a Chromium-based browser.": "tu navegador no admite el acceso a carpetas. Usa un navegador basado en Chromium.", + "Something went wrong while importing. Please try again.": "Algo salió mal al importar. Por favor inténtalo de nuevo.", + "Couldn't import": "No se pudo importar", + "Select folder": "Seleccionar carpeta", + "Import {{count}} conversations": "Importar {{count}} conversaciones", + "Done": "Hecho", + "Try again": "Intentar otra vez", + "My Agents": "Mis agentes", + "Imported conversations": "Conversaciones importadas", + "Agent": "Agente", + "Select a connected agent": "Selecciona un agente conectado", + "Consult Subagent": "Consultar subagente", + "DeepTutor asks": "DeepTutor pregunta", + "Max rounds DeepTutor may ask": "Rondas máximas que DeepTutor puede preguntar", + "Talk to an agent": "Habla con un agente", + "No connected agents — connect one in My Agents.": "No hay agentes conectados: conecte uno en Mis agentes.", + "No matching agent": "No hay agentes coincidentes", + "Fewer rounds": "Menos rondas", + "More rounds": "Más rondas", + "Add agent": "Añadir agente", + "No agents yet": "Aún no hay agentes", + "Your Claude Code and Codex sessions, organized by source — open one to keep chatting, or refresh to pull in new conversations.": "Tus sesiones de Claude Code y Codex, organizadas por fuente: abre una para seguir chateando o actualiza para incorporar nuevas conversaciones.", + "Add your Claude Code or Codex folder to bring its conversations in — then chat with them or refresh anytime.": "Añade tu carpeta Claude Code o Codex para incorporar tus conversaciones y luego charle con ellos o actualice en cualquier momento.", + "Search conversations...": "Buscar conversaciones...", + "Delete this conversation?": "¿Eliminar esta conversación?", + "Synced {{time}}": "Sincronizado {{time}}", + "Not synced yet": "Aún no sincronizado", + "Refresh history": "Actualizar historial", + "Added {{count}} new conversations": "Se han añadido {{count}} conversaciones nuevas", + "Already up to date": "Ya está actualizado", + "Refresh failed — try re-adding the agent.": "Error al actualizar: intente volver a añadir el agente.", + "Hidden folders like .claude won't show by default — press ⌘⇧. (Command-Shift-Period) in the picker to reveal them.": "Las carpetas ocultas como .claude no se mostrarán de forma predeterminada; presione ⌘⇧. (Comando-Mayús-Punto) en el selector para revelarlos.", + "My Agents Reference": "Referencia de mis agentes", + "Select agent conversations": "Seleccionar conversaciones con agentes", + "Bring conversations from your imported agents into this turn.": "Incorpora a este turno conversaciones de tus agentes importados.", + "Search conversations by title or last message": "Buscar conversaciones por título o último mensaje", + "No imported conversations found.": "No se encontraron conversaciones importadas.", + "1 conversation selected": "1 conversación seleccionada", + "{{n}} conversations selected": "{{n}} conversaciones seleccionadas", + "Use Selected ({{n}})": "Usar selección ({{n}})", + "Reference imported Claude Code / Codex conversations.": "Referencia a conversaciones importadas de Claude Code/Codex.", + "Agent name": "Nombre del agente", + "More": "Más", + "Select": "Seleccionar", + "Edit scope": "Editar alcance", + "Save scope": "Guardar alcance", + "Delete agent": "Eliminar agente", + "Ungrouped": "Desagrupados", + "All conversations": "Todas las conversaciones", + "{{count}} conversations": "{{count}} conversaciones", + "{{count}} projects": "{{count}} proyectos", + "{{count}} days": "{{count}} días", + "{{days}} days · {{sessions}} conversations": "{{days}} días · {{sessions}} conversaciones", + "Couldn't read this folder": "No se pudo leer esta carpeta", + "Pick an agent, then choose the conversations to bring in.": "Elige un agente y luego elige las conversaciones que desea incorporar.", + "Pick the days to bring in. Refreshing later re-syncs these days and pulls in new conversations on them.": "Elige los días que desee incorporar. Actualizar más tarde resincroniza estos días y genera nuevas conversaciones sobre ellos.", + "Pick the projects to bring in. Refreshing later re-syncs these projects and pulls in their new conversations.": "Elige los proyectos que desea incorporar. Al actualizarlos más tarde, se vuelven a sincronizar estos proyectos y se incorporan sus nuevas conversaciones.", + "Your Claude Code and Codex conversations as named agents — open one to keep chatting, edit what each imports, or refresh to pull in new conversations.": "tus conversaciones de Claude Code y Codex como agentes nombrados: abre uno para seguir conversando, edite lo que importa cada uno o actualice para incorporar nuevas conversaciones.", + "{{count}} conversations are no longer in scope. Remove them from your space?": "{{count}} conversaciones ya no están dentro del alcance. ¿Quitarlos de tu espacio?", + "Delete “{{name}}” and its {{count}} conversations? This removes them from your space and can't be undone.": "¿Eliminar “{{name}}” y tus {{count}} conversaciones? Esto los elimina de tu espacio y no se puede deshacer.", + "Delete “{{name}}”?": "¿Eliminar “{{name}}”?", + "Selected": "Seleccionado", + "This conversation has no readable messages.": "Esta conversación no tiene mensajes legibles.", + "Admin": "Administración", + "Admin — User Management": "Administrador: gestión de usuarios", + "User Management": "Gestión de usuarios", + "Manage registered accounts": "Administrar cuentas registradas", + "Add user": "Añadir usuario", + "Search users…": "Buscar usuarios…", + "Search users": "Buscar usuarios", + "{{filtered}} of {{total}}": "{{filtered}} de {{total}}", + "{{count}} user": "{{count}} usuario", + "{{count}} users": "{{count}} usuarios", + "No users yet": "Aún no hay usuarios", + "Accounts you create will appear here.": "Las cuentas que crea aparecerán aquí.", + "No users match “{{query}}”": "Ningún usuario coincide con \"{{query}}\"", + "Clear search": "Borrar búsqueda", + "Username": "Nombre de usuario", + "Role": "Rol", + "Actions": "Acciones", + "(you)": "(tú)", + "Manage assignments": "Administrar asignaciones", + "Cannot change your own role": "No puedes cambiar tu propio rol", + "Demote to user": "Degradar a usuario", + "Promote to admin": "Promocionar a administrador", + "Cannot delete your own account": "No puedes eliminar tu propia cuenta", + "Delete {{username}}": "Eliminar {{username}}", + "DeepTutor Admin · User Management": "Administrador de DeepTutor · Gestión de usuarios", + "Delete user": "Eliminar usuario", + "Promote": "Promover", + "Demote": "Degradar", + "Deleting…": "Eliminando…", + "Promoting…": "Promoviendo…", + "Demoting…": "Degradando...", + "{{role}} · joined {{date}}": "{{role}} · se unió a {{date}}", + "This permanently removes the account and its assignments. This cannot be undone.": "Esto elimina permanentemente la cuenta y sus asignaciones. Esto no se puede deshacer.", + "Admins can manage users and assignments, and work in the shared main workspace.": "Los administradores pueden administrar usuarios y asignaciones, y trabajar en el espacio de trabajo principal compartido.", + "They will lose access to the admin area and switch to their own assigned workspace.": "Perderán el acceso al área de administración y cambiarán a su propio espacio de trabajo asignado.", + "Username (or email)": "Nombre de usuario (o correo electrónico)", + "Password (≥ 8 chars)": "Contraseña (≥ 8 caracteres)", + "Creating…": "Creando…", + "Username is required.": "Se requiere nombre de usuario.", + "Password must be at least 8 characters.": "La contraseña debe tener al menos 8 caracteres.", + "Failed to load users": "No se pudieron cargar los usuarios", + "Failed to create user": "No se pudo crear el usuario", + "Failed to delete user": "No se pudo eliminar el usuario", + "Failed to update role": "No se pudo actualizar el rol", + "My profile": "mi perfil", + "View your account and personalize your avatar": "Ver tu cuenta y personalizar tu avatar", + "Administrator": "Administrador", + "Joined": "Fecha de alta", + "Avatar": "Avatar", + "Upload a picture or pick an icon": "Sube una imagen o elige un ícono", + "Upload image": "Subir imagen", + "Remove photo": "Quitar foto", + "Or pick an icon": "O elige un ícono", + "Sign out": "Cerrar sesión", + "End your session on this device": "Finaliza tu sesión en este dispositivo", + "Failed to load profile": "No se pudo cargar el perfil", + "Image is too large": "La imagen es demasiado grande.", + "Server URL": "URL del servidor", + "The base URL of your running LightRAG server. Documents are indexed there — nothing is uploaded or copied.": "La URL base de tu servidor LightRAG en ejecución. Los documentos se indexan allí: no se carga ni se copia nada.", + "Only if your server requires one": "Solo si tu servidor lo requiere", + "Could not connect": "No se pudo conectar", + "Connected to LightRAG server": "Conectado al servidor LightRAG", + "Core {{version}}": "Núcleo {{version}}", + "API key accepted": "Clave API aceptada", + "Open access": "Acceso abierto", + "codebuddy.auth.title": "Cuenta de CodeBuddy", + "codebuddy.auth.description": "Utiliza la sesión de CodeBuddy guardada en este equipo. No se envía ninguna clave de API a DeepTutor.", + "codebuddy.auth.checkAgain": "Volver a comprobar el inicio de sesión", + "codebuddy.auth.checking": "Comprobando la sesión local de CodeBuddy…", + "codebuddy.auth.loggedIn": "Sesión iniciada", + "codebuddy.auth.loggedInAs": "Sesión iniciada como {{user}}", + "codebuddy.auth.waiting": "Esperando la autorización del navegador…", + "codebuddy.auth.openAuthorization": "Abrir autorización", + "codebuddy.auth.cancel": "Cancelar", + "codebuddy.auth.signIn": "Iniciar sesión con CodeBuddy", + "codebuddy.auth.logout": "Cerrar sesión", + "codebuddy.auth.logoutExternal": "Esta sesión pertenece al complemento de CodeBuddy para el IDE o a la CLI. Cierra la sesión allí para desconectarla.", + "codebuddy.auth.sdkMissing": "Inicia sesión con el complemento de CodeBuddy para el IDE o ejecuta `codebuddy` e introduce /login; DeepTutor reutilizará esa sesión. Para iniciar sesión desde la aplicación: python -m pip install codebuddy-agent-sdk", + "codebuddy.auth.cliMissing": "Instala la CLI de CodeBuddy antes de iniciar sesión.", + "codebuddy.auth.timeout": "Se ha agotado el tiempo para iniciar sesión en CodeBuddy. Inténtalo de nuevo.", + "codebuddy.auth.failed": "No se ha podido comprobar ni iniciar la sesión de CodeBuddy.", + "codex.oauth.title": "OAuth de OpenAI Codex", + "codex.oauth.signIn": "Iniciar sesión con Codex", + "codex.oauth.waiting": "Esperando autorización del navegador...", + "codex.oauth.connected": "Codex está conectado.", + "codex.oauth.refresh": "Actualizar modelos", + "codex.oauth.logout": "desconectar", + "codex.oauth.experimental": "Conexión experimental: la superficie de compatibilidad del Codex anterior puede cambiar.", + "codex.oauth.isolated": "Las credenciales las almacena únicamente DeepTutor. Esto no lee ni modifica tu inicio de sesión de Codex CLI.", + "codex.oauth.ownerBound": "Inicia sesión con tu propio plan ChatGPT. Permanece en tu cuenta y nunca se comparte con otros usuarios de esta implementación.", + "codex.oauth.localOnly": "El acceso local abre la autorización directamente. El acceso remoto muestra primero un comando de túnel SSH.", + "codex.oauth.remoteTitle": "Autorización OAuth remota", + "codex.oauth.remoteSteps": "Primero ejecuta el siguiente comando y mantén el túnel SSH en funcionamiento. Luego haz clic en Abrir autorización.", + "codex.oauth.callbackAddress": "Dirección de devolución de llamada de OAuth", + "codex.oauth.copyCommand": "comando copiar", + "codex.oauth.commandCopied": "Se copió el comando del túnel SSH.", + "codex.oauth.copyFailed": "No se pudo copiar el comando del túnel SSH.", + "codex.oauth.openAuthorization": "Autorización abierta", + "codex.oauth.expiresIn": "Caduca en {{seconds}} segundos.", + "codex.oauth.callbackMissing": "El servidor DeepTutor no recibió la devolución de llamada de OAuth en localhost:{{port}}. Para un despliegue remoto, mantén el túnel SSH en ejecución y vuelve a intentarlo.", + "codex.oauth.callbackMissingUnknown": "El servidor DeepTutor no recibió la devolución de llamada de OAuth. Inicia sesión de nuevo y sigue las instrucciones de autorización del nuevo intento.", + "codex.oauth.callbackUnavailable": "DeepTutor no puede escuchar ni en localhost:1455 ni en localhost:1457. Libera uno de estos puertos y vuelve a intentarlo.", + "codex.oauth.invalidResponse": "El endpoint OAuth de DeepTutor devolvió una respuesta inesperada. Comprueba que el proxy inverso reenvíe correctamente /api/v1/settings/providers/openai-codex/.", + "codex.oauth.activated": "Codex está conectado y ahora el modelo activo.", + "codex.oauth.reloadDeferred": "Codex está conectado. Aplica los cambios pendientes para ver tus modelos en la lista.", + "codex.oauth.catalogFailed": "No se pudo actualizar el catálogo de modelos del Codex. Se conservaron tu inicio de sesión y modelo actual.", + "codex.oauth.inferenceActive": "Espera a que termine la respuesta activa de Codex antes de cerrar sesión.", + "codex.oauth.disconnected": "Codex no está conectado.", + "codex.oauth.cancel": "Cancelar", + "codex.oauth.cancelled": "El inicio de sesión del Codex fue cancelado.", + "codex.oauth.expired": "El inicio de sesión de Codex ha caducado. Empieza de nuevo.", + "codex.oauth.denied": "Se denegó la autorización del Codex.", + "codex.oauth.requestFailed": "La solicitud de Codex ha fallado. Vuelve a intentarlo.", + "codex.oauth.modelCount": "Modelos: {{count}}", + "codex.oauth.reasoningTitle": "Nivel de razonamiento", + "codex.oauth.reasoningDescription": "Elige un valor predeterminado para cada modelo de Codex. Solo se muestran los niveles compatibles con ese modelo.", + "codex.oauth.reasoningSaved": "Se ha guardado el nivel de razonamiento de Codex.", + "codex.oauth.reasoningUnsupported": "Ese nivel de razonamiento ya no es compatible. Actualiza los modelos y vuelve a elegirlo.", + "codex.oauth.reasoningCatalogChanged": "La lista de modelos de Codex ha cambiado. Actualiza los modelos e inténtalo de nuevo.", + "Jump to one of your questions": "Ir a una de tus preguntas", + "Open navigation": "Abrir la navegación", + "contextBudget.title": "Ventana de contexto", + "contextBudget.chipAria": "Ventana de contexto: {{percent}} usado", + "contextBudget.usage": "{{used}} / {{total}} ({{percent}})", + "contextBudget.estimated": "est.", + "contextBudget.freeSpace": "Espacio libre", + "contextBudget.note.estimatedWindow": "El tamaño de la ventana se deduce del nombre del modelo. Puedes configurarlo para cada modelo en Ajustes.", + "contextBudget.note.heuristicCounter": "El recuento de tokens es una estimación aproximada basada en el número de caracteres.", + "contextBudget.note.deferredTools_one": "Hay 1 herramienta adicional que se carga bajo demanda; su esquema no ocupa espacio hasta que se utiliza.", + "contextBudget.note.deferredTools_other": "Hay {{count}} herramientas adicionales que se cargan bajo demanda; sus esquemas no ocupan espacio hasta que se utilizan.", + "contextBudget.segment.messages": "Mensajes", + "contextBudget.segment.system_prompt": "Prompt del sistema", + "contextBudget.segment.system_tools": "Herramientas del sistema", + "contextBudget.segment.mcp_tools": "Herramientas MCP", + "contextBudget.segment.tool_manifest": "Manifiesto de herramientas", + "contextBudget.segment.extended_tools": "Herramientas adicionales", + "contextBudget.segment.persona_style": "Estilo de la persona", + "contextBudget.segment.partner_turn_policy": "Política del turno del compañero", + "contextBudget.segment.memory": "Memoria", + "contextBudget.segment.knowledge_base_note": "Nota de la base de conocimiento", + "contextBudget.segment.skills": "Habilidades", + "contextBudget.segment.sources": "Fuentes", + "contextBudget.segment.notebooks": "Cuadernos", + "contextBudget.segment.workspace": "Espacio de trabajo", + "contextBudget.segment.capability": "Capacidades", + "Server name is required.": "El nombre del servidor es obligatorio.", + "Server name must start alphanumeric and use only letters, digits, - or _ (max 64).": "El nombre del servidor debe empezar por una letra o un número y solo puede contener letras, números, - o _ (máximo 64 caracteres).", + "A server with this name already exists.": "Ya existe un servidor con este nombre.", + "Configure either a command (stdio) or a url first.": "Configura primero un comando (stdio) o una URL.", + "Enter the server's https URL first.": "Introduce primero la URL HTTPS del servidor.", + "Connection failed.": "La conexión ha fallado.", + "Transport": "Transporte", + "Auto-detect": "Detección automática", + "Standard I/O (local process)": "Entrada/salida estándar (proceso local)", + "Command": "Comando", + "Arguments (one per line)": "Argumentos (uno por línea)", + "Working directory (optional)": "Directorio de trabajo (opcional)", + "Environment variables": "Variables de entorno", + "Remote (SSE / streamable HTTP)": "Remoto (SSE / HTTP en streaming)", + "HTTP headers": "Cabeceras HTTP", + "Tool timeout (seconds)": "Tiempo de espera de la herramienta (segundos)", + "Enabled tools (one per line, * = all)": "Herramientas habilitadas (una por línea, * = todas)", + "Connected — {{count}} tools detected": "Conectado: se han detectado {{count}} herramientas", + "No MCP servers yet": "Todavía no hay servidores MCP", + "Add a server to expose its tools to the chat agent. Test the connection first, then save.": "Añade un servidor para que el agente de chat pueda usar sus herramientas. Prueba primero la conexión y después guarda los cambios.", + "Stored on the server and never shown here.": "Se guarda en el servidor y nunca se muestra aquí.", + "configured": "configurado", + "Connected": "Conectado", + "Connecting": "Conectando", + "Disabled": "Desactivado", + "Exposed tools": "Herramientas expuestas", + "Add row": "Añadir fila", + "Delete MCP server \"{{name}}\"?": "¿Eliminar el servidor MCP «{{name}}»?", + "Add MCP server": "Añadir servidor MCP", + "Add server": "Añadir servidor", + "Failed to load MCP servers": "No se han podido cargar los servidores MCP", + "MCP Services": "Servicios MCP", + "Connect hosted MCP services to your account — their tools become available to the chat agent.": "Conecta servicios MCP alojados a tu cuenta para que el agente de chat pueda usar sus herramientas.", + "{{used}} / {{max}} connected": "{{used}} / {{max}} conectados", + "Store": "Catálogo", + "Add a server by URL": "Añadir un servidor mediante URL", + "Browse the store": "Explorar el catálogo", + "Not connected": "Sin conexión", + "Provided by this deployment": "Proporcionado por este despliegue", + "Read-only": "Solo lectura", + "Deployment registry": "Registro del despliegue", + "Servers every account on this deployment can use. Local commands (stdio) are only configurable here.": "Servidores que pueden usar todas las cuentas de este despliegue. Los comandos locales (stdio) solo se pueden configurar aquí.", + "Search MCP services…": "Buscar servicios MCP…", + "No services match this filter.": "Ningún servicio coincide con este filtro.", + "{{shown}} of {{total}} services": "{{shown}} de {{total}} servicios", + "Load more": "Cargar más", + "View details": "Ver detalles", + "Installed": "Instalado", + "Unverified": "Sin verificar", + "Needs a credential": "Requiere credenciales", + "Back to the store": "Volver al catálogo", + "Documentation": "Documentación", + "Website": "Sitio web", + "Install as": "Instalar como", + "Credentials are stored apart from the server config and are never sent back to this page.": "Las credenciales se guardan por separado de la configuración del servidor y nunca se devuelven a esta página.", + "You have reached your MCP server limit. Remove one first.": "Has alcanzado el límite de servidores MCP. Elimina uno antes de continuar.", + "Testing the connection…": "Probando la conexión…", + "Install": "Instalar", + "Reinstall": "Reinstalar", + "Fill in {{fields}} to continue": "Completa {{fields}} para continuar", + "mcp.category.search": "Búsqueda", + "mcp.category.docs": "Documentación", + "mcp.category.code": "Código", + "mcp.category.data": "Datos", + "mcp.category.browser": "Navegador", + "mcp.category.science": "Ciencia", + "mcp.category.maps": "Mapas", + "mcp.category.business": "Negocios", + "mcp.category.ai": "IA", + "mcp.category.utility": "Utilidades", + "mcp.tier.curated": "Seleccionado", + "mcp.tier.registry": "Registro", + "mcp.refusal.stdioNotAllowed": "Los servidores que añadas deben usar una URL remota. Los comandos locales se ejecutan en el sistema anfitrión, por lo que solo puede añadirlos un administrador.", + "mcp.refusal.blockedUrl": "Esa dirección no está permitida. Usa una URL HTTPS pública para el servidor MCP.", + "mcp.refusal.nameReserved": "Ese nombre pertenece a un servidor del despliegue. Instálalo con otro nombre.", + "mcp.refusal.tooManyServers": "Has alcanzado el límite de servidores MCP. Elimina uno antes de continuar.", + "mcp.refusal.missingCredential": "Falta una credencial obligatoria. Completa todos los campos obligatorios y vuelve a intentarlo.", + "mcp.refusal.entryAdminOnly": "Este servicio se ejecuta como un comando local, por lo que solo puede añadirlo un administrador.", + "The store is empty": "El catálogo está vacío", + "This deployment ships no installable MCP services yet. You can still add one by URL.": "Este despliegue todavía no incluye servicios MCP instalables, pero puedes añadir uno mediante su URL.", + "CLI Apps": "Aplicaciones CLI", + "Command-line tools from the CLI-Anything catalog. Once enabled, the chat agent can call them directly.": "Herramientas de línea de comandos del catálogo CLI-Anything. Una vez habilitadas, el agente de chat puede usarlas directamente.", + "{{count}} available to you": "{{count}} disponibles para ti", + "No CLI apps installed": "No hay aplicaciones CLI instaladas", + "Browse the store to install one. Installing runs the app's own installer on this server, so it stays an administrator action.": "Explora el catálogo para instalar una. La instalación ejecuta el instalador de la aplicación en este servidor, por lo que debe realizarla un administrador.", + "An administrator installs these, then assigns them to accounts. Browse the store to see what you could ask for by name.": "Un administrador instala estas aplicaciones y después las asigna a las cuentas. Explora el catálogo para ver cuáles puedes solicitar por su nombre.", + "Your account cannot run code, so no CLI app is available to the chat agent — even the ones listed below.": "Tu cuenta no puede ejecutar código, por lo que el agente de chat no puede usar ninguna aplicación CLI, ni siquiera las que aparecen a continuación.", + "Not assigned to your account. An administrator has to grant it before you can use it.": "No está asignada a tu cuenta. Un administrador debe concederte acceso antes de que puedas usarla.", + "No longer in the catalog": "Ya no está en el catálogo", + "Installed {{when}}": "Instalada {{when}}", + "Uninstall": "Desinstalar", + "Remove \"{{name}}\" from this deployment?": "¿Eliminar «{{name}}» de este despliegue?", + "Installed under": "Instalada en", + "mounted read-only into the sandbox that runs them.": "montadas en modo de solo lectura en el entorno aislado que las ejecuta.", + "Search CLI apps…": "Buscar aplicaciones CLI…", + "Include unavailable": "Incluir las no disponibles", + "Nothing matches": "No hay coincidencias", + "Try a different search or clear the filters.": "Prueba otra búsqueda o borra los filtros.", + "{{shown}} of {{total}} apps": "{{shown}} de {{total}} aplicaciones", + "Unavailable here": "No disponible aquí", + "Pinned": "Fijada", + "Third-party": "De terceros", + "Third-party, unpinned": "De terceros, sin fijar", + "Notes": "Notas", + "Installs": "Instalaciones", + "This resolves to whatever that source publishes today — it is not pinned to a reviewed revision.": "Esto apunta a lo que publique actualmente esa fuente; no está fijado a una revisión comprobada.", + "Tool name in chat": "Nombre de la herramienta en el chat", + "Not installable here.": "No se puede instalar aquí.", + "Only an administrator can install this. Once installed they can assign it to your account.": "Solo puede instalarla un administrador. Una vez instalada, podrá asignarla a tu cuenta.", + "Installing runs the app's own installer on this server, so it is an administrator action. You can browse here and ask for an app by name.": "La instalación ejecuta el instalador de la aplicación en este servidor, por lo que debe realizarla un administrador. Puedes explorar el catálogo y solicitar una aplicación por su nombre.", + "This can take a few minutes — it builds a fresh environment.": "Puede tardar unos minutos porque crea un entorno nuevo.", + "Runs on this server as the application user.": "Se ejecuta en este servidor con el usuario de la aplicación.", + "Install output": "Resultado de la instalación", + "Something went wrong.": "Algo ha salido mal.", + "Using {{service}}": "Usando {{service}}", + "Running {{app}}": "Ejecutando {{app}}", + "Product names and logos are trademarks of their respective owners, shown for identification only.": "Los nombres y logotipos de los productos son marcas de sus respectivos propietarios y solo se muestran con fines identificativos.", + "Needs authorization": "Requiere autorización", + "Reauthorize": "Volver a autorizar", + "Frontend": "Interfaz web", + "Sandbox": "Entorno aislado", + "Supervisor": "Supervisor", + "Other": "Otros", + "System available": "Disponible en el sistema", + "Container available": "Disponible en el contenedor", + "{{used}} · {{percent}}% of {{total}}": "{{used}} · {{percent}}% de {{total}}", + "(backend only)": "(solo backend)", + "Only the backend and its own children. Start DeepTutor through the launcher to include the web server.": "Solo se incluyen el backend y sus procesos secundarios. Inicia DeepTutor mediante el lanzador para incluir el servidor web.", + "Refresh models": "Actualizar modelos", + "Starting points": "Puntos de partida", + "The three lines under the composer on an empty home screen, generated from your long-term memory and what you have been working on lately.": "Las tres sugerencias que aparecen bajo el cuadro de texto cuando la pantalla de inicio está vacía, generadas a partir de tu memoria a largo plazo y de aquello en lo que has trabajado recientemente.", + "Material": "Contenido", + "Your consolidated memory is always included. This controls how much raw recent activity goes with it — conversations, practice questions, searches, documents, in one list ordered by time.": "Tu memoria consolidada se incluye siempre. Esta opción controla cuánta actividad reciente sin procesar la acompaña —conversaciones, preguntas de práctica, búsquedas y documentos— en una lista ordenada cronológicamente.", + "Recent activities": "Actividad reciente", + "More gives the model a longer view of what you have touched; fewer keeps it focused on this week. Takes effect the next time the lines are regenerated.": "Una cantidad mayor ofrece al modelo una visión más amplia de lo que has consultado; una menor lo mantiene centrado en esta semana. Se aplicará la próxima vez que se regeneren las sugerencias.", + "Loading starter settings...": "Cargando la configuración de sugerencias...", + "Failed to load starter settings.": "No se pudo cargar la configuración de sugerencias.", + "Failed to save starter settings.": "No se pudo guardar la configuración de sugerencias.", + "Suggest what to explore next": "Sugerir qué explorar a continuación", + "Finding what to explore next...": "Buscando qué explorar a continuación...", + "Not enough history yet — have a conversation first.": "Aún no hay suficiente historial; inicia primero una conversación.", + "Suggest something else": "Sugerir otra cosa", + "{{provider}} requires an API key. It will fail hard without credentials.": "{{provider}} requiere una clave API y no funcionará sin credenciales.", + "Deprecated provider. Switch to one of: {{providers}}.": "Proveedor obsoleto. Cambia a uno de estos: {{providers}}.", + "Unsupported provider. Use one of: {{providers}}.": "Proveedor no compatible. Usa uno de estos: {{providers}}.", + "One IMA account reaches every library in it, so this pair is shared by all your Tencent IMA knowledge bases. Connecting one then only asks which library to point at. A knowledge base can still carry its own credentials to reach a second account.": "Una cuenta de IMA permite acceder a todas sus bibliotecas, por lo que estas credenciales se comparten entre todas tus bases de conocimientos de Tencent IMA. Al conectar una, solo tendrás que indicar la biblioteca correspondiente. Aun así, cada base de conocimientos puede usar sus propias credenciales para acceder a otra cuenta.", + "Enter your IMA Client ID": "Introduce tu ID de cliente de IMA", + "Enter your IMA API key": "Introduce tu clave API de IMA", + "Client ID": "ID de cliente", + "Using the IMA credentials configured for this engine in the Knowledge Center.": "Se están usando las credenciales de IMA configuradas para este motor en el Centro de conocimiento.", + "Use other credentials": "Usar otras credenciales", + "Use the engine's credentials instead": "Usar las credenciales del motor", + "Set these once for the engine in the Knowledge Center to skip this step next time.": "Configúralas una vez para el motor en el Centro de conocimiento y podrás omitir este paso la próxima vez.", + "Open IMA": "Abrir IMA", + "Choose from knowledge base list": "Elegir de la lista de bases de conocimientos", + "Use knowledge base ID instead": "Usar el ID de la base de conocimientos", + "Verify and load knowledge bases": "Verificar y cargar las bases de conocimientos", + "No accessible knowledge bases found.": "No se encontraron bases de conocimientos accesibles.", + "Knowledge Base ID": "ID de la base de conocimientos", + "Verify knowledge base": "Verificar la base de conocimientos", + "Knowledge base verified": "Base de conocimientos verificada", + "Read only": "Solo lectura", + "IMA Client ID and API key configured": "ID de cliente y clave API de IMA configurados", + "Add them under Credentials, or supply a pair per knowledge base when connecting one.": "Añádelos en Credenciales o proporciona un par para cada base de conocimientos al conectarla.", + "Test compatibility": "Probar compatibilidad", + "Testing compatibility…": "Probando la compatibilidad…", + "Use this model": "Usar este modelo", + "Runs one small structured-output request and may incur provider charges. The global active chat model changes only after you choose Use this model.": "Ejecuta una pequeña solicitud de salida estructurada que puede generar cargos del proveedor. El modelo de chat activo global solo cambiará cuando elijas «Usar este modelo».", + "Compatible with GraphRAG": "Compatible con GraphRAG", + "Incompatible with GraphRAG": "No compatible con GraphRAG", + "Compatibility could not be verified": "No se pudo verificar la compatibilidad", + "The model returned valid GraphRAG structured output.": "El modelo devolvió una salida estructurada válida para GraphRAG.", + "The model did not accept or return the structured output required by GraphRAG.": "El modelo no aceptó ni devolvió la salida estructurada que requiere GraphRAG.", + "GraphRAG compatibility could not be verified because the model credentials were rejected.": "No se pudo verificar la compatibilidad con GraphRAG porque se rechazaron las credenciales del modelo.", + "GraphRAG compatibility could not be verified because the model provider is rate limiting requests. Try again later.": "No se pudo verificar la compatibilidad con GraphRAG porque el proveedor del modelo está limitando las solicitudes. Inténtalo de nuevo más tarde.", + "GraphRAG compatibility could not be verified because the model provider could not be reached. Try again later.": "No se pudo verificar la compatibilidad con GraphRAG porque no se pudo contactar con el proveedor del modelo. Inténtalo de nuevo más tarde.", + "GraphRAG compatibility could not be verified because the model response reached its output token limit. Try again.": "No se pudo verificar la compatibilidad con GraphRAG porque la respuesta del modelo alcanzó el límite de tokens de salida. Inténtalo de nuevo.", + "GraphRAG compatibility could not be verified with this model.": "No se pudo verificar la compatibilidad con GraphRAG usando este modelo.", + "The configured GraphRAG model or endpoint was not found. Check its provider URL.": "No se encontró el modelo o endpoint configurado para GraphRAG. Comprueba la URL del proveedor.", + "GraphRAG cannot use this OAuth-only model provider. Choose an API-key profile.": "GraphRAG no puede usar este proveedor de modelos exclusivo de OAuth. Elige un perfil con clave API.", + "GraphRAG does not support the selected model provider transport.": "GraphRAG no admite el método de conexión del proveedor de modelos seleccionado.", + "Indexing failed. Review the error details, update the configuration if needed, then retry.": "La indexación ha fallado. Revisa los detalles del error, actualiza la configuración si es necesario y vuelve a intentarlo.", + "Open model settings": "Abrir la configuración de modelos", + "Generate all chapters": "Generar todos los capítulos", + "First chapter only": "Solo el primer capítulo", + "Confirm spine": "Confirmar la estructura", + "Chapters will be generated the first time you open them — useful when you want to read as you go, or keep an eye on model usage.": "Los capítulos se generarán la primera vez que los abras. Resulta útil para leer sobre la marcha o controlar el uso del modelo.", + "~{{count}} words": "~{{count}} palabras", + "~{{count}} min to generate": "~{{count}} min para generar", + "Generation paused": "Generación en pausa", + "Your model provider kept refusing requests, so the remaining chapters were left untouched rather than half-written. Everything generated so far is saved.": "El proveedor del modelo siguió rechazando solicitudes, así que los capítulos restantes se dejaron intactos en lugar de generarlos a medias. Todo lo generado hasta ahora está guardado.", + "Resume generation": "Reanudar la generación", + "Resuming…": "Reanudando…", + "Compilation paused: {{reason}}": "Compilación en pausa: {{reason}}", + "repeated provider errors": "errores reiterados del proveedor", + "Could not resume: {{message}}": "No se pudo reanudar: {{message}}", + "Continue reading": "Continuar leyendo", + "Start reading": "Empezar a leer", + "Continue setup": "Continuar la configuración", + "{{percent}}% read": "{{percent}} % leído", + "Read": "Leído", + "Unread": "Sin leer", + "Bookmark this chapter": "Marcar este capítulo", + "Remove bookmark": "Quitar marcador", + "← / → to turn pages": "← / → para pasar de página", + "Edit text": "Editar texto", + "Edit note": "Editar nota", + "Empty note — click to start writing your own annotation.": "Nota vacía; haz clic para escribir tu propia anotación.", + "Empty note.": "Nota vacía.", + "Markdown supported. ⌘/Ctrl + Enter to save.": "Admite Markdown. Pulsa ⌘/Ctrl + Intro para guardar.", + "Markdown supported. ⌘/Ctrl + Enter to save, Esc to cancel.": "Admite Markdown. Pulsa ⌘/Ctrl + Intro para guardar o Esc para cancelar.", + "Could not save your edit: {{message}}": "No se pudo guardar el cambio: {{message}}", + "How did you do?": "¿Qué tal te ha ido?", + "Missed it": "Respuesta incorrecta", + "Missed one? I can add a worked explanation and an easier set.": "¿Has fallado alguna? Puedo añadir una explicación paso a paso y una serie más sencilla.", + "Add extra practice": "Añadir práctica adicional", + "Adding…": "Añadiendo…", + "Could not add extra practice: {{message}}": "No se pudo añadir práctica adicional: {{message}}", + "Compile failed: {{message}}": "La compilación ha fallado: {{message}}", + "Regenerate block failed: {{message}}": "No se pudo regenerar el bloque: {{message}}", + "planning": "planificando", + "generating": "generando", + "partial": "parcial", + "paused": "en pausa", + "Draft": "Borrador", + "Outline": "Esquema", + "Paused": "En pausa", + "Brief": "Breve", + "Roughly half the prose per chapter — a fast orientation.": "Aproximadamente la mitad de texto por capítulo, para obtener una visión general rápida.", + "The default balance of explanation, examples and practice.": "El equilibrio predeterminado entre explicaciones, ejemplos y práctica.", + "Longer treatments with more worked detail. Takes noticeably longer.": "Explicaciones más extensas y con más desarrollo. Tarda bastante más.", + "Flash cards": "Tarjetas didácticas", + "Deep dive": "Análisis en profundidad", + "Concept graph": "Mapa conceptual", + "Previous chapter": "Capítulo anterior", + "Next chapter": "Capítulo siguiente", + "Got it right": "Respuesta correcta", + "Rebuilding…": "Reconstruyendo…", + "Click again to rebuild": "Haz clic de nuevo para reconstruir", + "Replaces every generated chapter and clears your reading progress, bookmarks, notes and edits.": "Sustituye todos los capítulos generados y borra tu progreso de lectura, marcadores, notas y cambios.", + "Click again to delete this block": "Haz clic de nuevo para eliminar este bloque", + "{{action}} failed: {{reason}}": "{{action}} ha fallado: {{reason}}", + "Open the chapter this created": "Abrir el capítulo creado", + "Generate a chapter on this": "Generar un capítulo sobre esto", + "This book was written without reading your sources.": "Este libro se ha escrito sin consultar tus fuentes.", + "Retrieval failed while planning it, so the chapters come from the proposal alone. Rebuilding will try your knowledge bases again.": "La recuperación falló durante la planificación, por lo que los capítulos se basan únicamente en la propuesta. Al reconstruirlos se volverán a consultar tus bases de conocimientos.", + "Source sweep failed": "La consulta de fuentes ha fallado", + "PageIndex versions are model-insensitive and preserve rebuild history.": "Las versiones de PageIndex son independientes del modelo y conservan el historial de reconstrucciones.", + "Rebuild this PageIndex knowledge base. Existing index versions are preserved.": "Reconstruye esta base de conocimientos de PageIndex. Se conservarán las versiones existentes del índice.", + "Whisper": "Whisper", + "Whisper tooltip": "Sala Whisper de dos puestos para visitante y aprendiz.", + "Enter the key on the settings page — never in chat, so it stays out of the conversation.": "Introduce la clave en la página de configuración, nunca en el chat, para que no forme parte de la conversación.", + "Open settings": "Abrir la configuración", + "This step needs credentials": "Este paso requiere credenciales", + "Add to folder": "Añadir a la carpeta", + "Knowledge base root": "Raíz de la base de conocimientos", + "Hosted engine: the library lives in Tencent IMA and DeepTutor keeps no copy. Requires an IMA Client ID and API key. Chat searches it, browses its documents, reads full sources, and — only when you ask — collects a web page or saves a note.": "Motor alojado: la biblioteca reside en Tencent IMA y DeepTutor no conserva ninguna copia. Requiere un ID de cliente y una clave API de IMA. El chat busca en ella, explora sus documentos, lee las fuentes completas y, solo cuando se lo pides, recopila una página web o guarda una nota.", + "DeepTutor searches and browses this library, and reads full sources when a matched snippet is too short. It only writes to IMA when you ask it to.": "DeepTutor busca y explora esta biblioteca, y lee las fuentes completas cuando un fragmento coincidente es demasiado corto. Solo escribe en IMA cuando se lo pides.", + "Index mode": "Modo de indexación", + "Default — Flash, summaries and full optimization": "Predeterminado: Flash, resúmenes y optimización completa", + "Uses the globally active LLM credential. Leaving this unset delegates to the PageIndex SDK default.": "Utiliza la credencial del LLM activa globalmente. Si se deja sin configurar, se usará el valor predeterminado del SDK de PageIndex.", + "PDF only — use PageIndex Cloud for Office, Markdown or CSV": "Solo PDF; usa PageIndex Cloud para archivos de Office, Markdown o CSV", + "This engine isn't ready on the server. Check its requirements before creating.": "Este motor no está listo en el servidor. Comprueba sus requisitos antes de crear la base.", + "Local chunkless and vectorless indexing. Accepts PDF files and uses the globally active chat model credential for indexing; the index remains readable after model changes.": "Indexación local sin fragmentos ni vectores. Acepta archivos PDF y utiliza para la indexación la credencial del modelo de chat activa globalmente; el índice seguirá siendo legible después de cambiar de modelo.", + "PageIndex API plans": "Planes de la API de PageIndex", + "A record needs a title.": "El registro necesita un título.", + "Body": "Contenido", + "Copy to notebook": "Copiar al cuaderno", + "Could not load your notebooks": "No se pudieron cargar tus cuadernos", + "Delete \"{{name}}\" and its {{count}} records? This cannot be undone.": "¿Eliminar \"{{name}}\" y sus {{count}} registros? Esta acción no se puede deshacer.", + "Delete \"{{name}}\"?": "¿Eliminar \"{{name}}\"?", + "Delete notebook": "Eliminar cuaderno", + "Delete the record \"{{title}}\"?": "¿Eliminar el registro \"{{title}}\"?", + "Description (optional)": "Descripción (opcional)", + "Discard your unsaved changes?": "¿Descartar los cambios sin guardar?", + "Edit Markdown": "Editar Markdown", + "Edit notebook": "Editar cuaderno", + "Filter notebooks": "Filtrar cuadernos", + "Move to notebook": "Mover al cuaderno", + "Name your first notebook": "Ponle nombre a tu primer cuaderno", + "New notebook": "Nuevo cuaderno", + "No notebook selected": "No hay ningún cuaderno seleccionado", + "No notebooks match your filter.": "Ningún cuaderno coincide con el filtro.", + "No notebooks yet.": "Aún no hay cuadernos.", + "No records match your search": "Ningún registro coincide con la búsqueda", + "Pick a notebook on the left, or create one to get started.": "Selecciona un cuaderno de la izquierda o crea uno para empezar.", + "Record title": "Título del registro", + "Save something to it from a chat, research run, or Co-Writer document.": "Guarda contenido en él desde un chat, una investigación o un documento de Co-Writer.", + "Search records in this notebook": "Buscar registros en este cuaderno", + "Short summary": "Resumen breve", + "This notebook could not be opened": "No se pudo abrir este cuaderno", + "This notebook is empty": "Este cuaderno está vacío", + "Try a different word.": "Prueba con otra palabra.", + "No notebook accepted this save. Their files may be missing or damaged.": "No se pudo guardar en ningún cuaderno. Puede que sus archivos falten o estén dañados.", + "Could not save to: {{names}}": "No se pudo guardar en: {{names}}", + "Record actions": "Acciones del registro", + "Copy to": "Copiar a", + "Record saved": "Registro guardado", + "Record deleted": "Registro eliminado", + "Moved to \"{{name}}\"": "Movido a \"{{name}}\"", + "Copied to \"{{name}}\"": "Copiado a \"{{name}}\"", + "Delete this record?": "¿Eliminar este registro?", + "\"{{title}}\" will be removed from this notebook.": "\"{{title}}\" se eliminará de este cuaderno.", + "Discard your changes?": "¿Descartar los cambios?", + "Discard": "Descartar", + "Your edits to this record have not been saved yet.": "Los cambios de este registro aún no se han guardado.", + "Write": "Escribir", + "Delete this notebook?": "¿Eliminar este cuaderno?", + "\"{{name}}\" and its {{count}} records will be deleted. This cannot be undone.": "Se eliminarán \"{{name}}\" y sus {{count}} registros. Esta acción no se puede deshacer.", + "\"{{name}}\" will be deleted.": "Se eliminará \"{{name}}\".", + "Deleted \"{{name}}\"": "Se ha eliminado \"{{name}}\"", + "Notebook exported": "Cuaderno exportado", + "Immersive Reading": "Lectura inmersiva", + "Read a document with the assistant, cited line by line": "Lee un documento con el asistente y citas línea por línea", + "Immersive reading": "Lectura inmersiva", + "Open a document to read": "Abre un documento para leerlo", + "Drop a PDF, EPUB, Word, slide deck or text file here. The assistant reads it with you and cites what it uses.": "Suelta aquí un archivo PDF, EPUB, Word, una presentación o un archivo de texto. El asistente lo leerá contigo y citará el contenido que utilice.", + "Up to {{mb}} MB.": "Hasta {{mb}} MB.", + "Preparing document…": "Preparando el documento…", + "Opening document…": "Abriendo el documento…", + "Recently read": "Leídos recientemente", + "Close document": "Cerrar documento", + "Close reader": "Cerrar lector", + "Export annotated file": "Exportar archivo con anotaciones", + "Export failed.": "No se pudo exportar.", + "Annotations": "Anotaciones", + "Auto-jump on — the view follows what the assistant reads": "Desplazamiento automático activado: la vista sigue lo que lee el asistente", + "Auto-jump off — the assistant will not move your view": "Desplazamiento automático desactivado: el asistente no moverá la vista", + "Annotate selection": "Anotar selección", + "Highlight": "Resaltar", + "Underline": "Subrayar", + "Add note": "Añadir nota", + "Ask about this": "Preguntar sobre esto", + "Your note…": "Tu nota…", + "Save note": "Guardar nota", + "Yellow": "Amarillo", + "Green": "Verde", + "Blue": "Azul", + "Pink": "Rosa", + "Purple": "Morado", + "No annotations yet": "Aún no hay anotaciones", + "Select text in the document to highlight it or attach a note.": "Selecciona texto del documento para resaltarlo o adjuntarle una nota.", + "Delete annotation": "Eliminar anotación", + "{{count}} annotations": "{{count}} anotaciones", + "AI": "IA", + "Page": "Página", + "Chapter": "Capítulo", + "Slide": "Diapositiva", + "{{unit}} {{n}} of {{total}}": "{{unit}} {{n}} de {{total}}", + "This document could not be opened.": "No se pudo abrir este documento.", + "This file could not be opened.": "No se pudo abrir este archivo.", + "This page could not be rendered.": "No se pudo mostrar esta página.", + "This section has no extractable text.": "Esta sección no contiene texto que se pueda extraer.", + "Could not load this section.": "No se pudo cargar esta sección.", + "That annotation could not be saved.": "No se pudo guardar la anotación.", + "That annotation could not be removed.": "No se pudo eliminar la anotación.", + "Explain this passage": "Explica este pasaje", + "Resize the reader": "Cambiar el tamaño del lector", + "Drag to resize · double-click to reset": "Arrastra para cambiar el tamaño · haz doble clic para restablecerlo", + "Available only after every stale page has been recompiled.": "Disponible únicamente después de volver a compilar todas las páginas desactualizadas.", + "Mark as seen anyway": "Marcar como visto de todos modos", + "Dismiss the warning without recompiling those pages.": "Descartar el aviso sin volver a compilar esas páginas.", + "Add to category": "Añadir a una categoría", + "No categories yet. Type a name below to create one.": "Aún no hay categorías. Escribe un nombre debajo para crear una.", + "New category…": "Nueva categoría…", + "Create and add": "Crear y añadir", + "Search questions and answers…": "Buscar preguntas y respuestas…", + "Toggle sort order": "Cambiar el orden", + "Newest first": "Más recientes primero", + "Oldest first": "Más antiguas primero", + "Unfiled": "Sin clasificar", + "No matching questions": "No hay preguntas que coincidan", + "Try a different word, or clear the search to see everything.": "Prueba con otra palabra o borra la búsqueda para verlo todo.", + "Everything is filed": "Todo está clasificado", + "No questions are waiting to be sorted into a category.": "No hay preguntas pendientes de clasificar en una categoría.", + "Nothing in this view": "No hay nada en esta vista", + "Switch to another filter to see your other questions.": "Cambia de filtro para ver las demás preguntas.", + "Showing {{shown}} of {{total}} — narrow the view to see the rest.": "Se muestran {{shown}} de {{total}}; acota la vista para ver el resto.", + "Delete this category? The questions themselves stay in your bank.": "¿Eliminar esta categoría? Las preguntas permanecerán en tu banco.", + "Indexing": "Indexación", + "Applies the next time a knowledge base is built or rebuilt.": "Se aplicará la próxima vez que se cree o reconstruya una base de conocimientos.", + "Files in parallel": "Archivos en paralelo", + "Higher finishes sooner but uses more memory": "Un valor mayor termina antes, pero utiliza más memoria", + "Concurrent LLM calls": "Llamadas simultáneas al LLM", + "Lower this if your provider rate-limits you": "Reduce este valor si tu proveedor limita la frecuencia de solicitudes", + "Extra extraction passes": "Pasadas de extracción adicionales", + "Recovers missed entities; each pass costs another call": "Recupera entidades omitidas; cada pasada consume otra llamada", + "Copy room id": "Copiar el ID de la sala", + "Counselor line as trainee…": "Intervención como terapeuta en prácticas…", + "Crisis redirect detected. Sending is disabled for this room.": "Se ha detectado una derivación por crisis. No se pueden enviar mensajes en esta sala.", + "Dual-seat supervision · seat switch (not split-screen)": "Supervisión con dos roles · cambio de rol (sin pantalla dividida)", + "Dual-seat whisper — 3 steps": "Susurro con dos roles — 3 pasos", + "End session": "Finalizar sesión", + "Message as visitor…": "Mensaje como visitante…", + "New room": "Nueva sala", + "Open the room as Visitor first.": "Abre primero la sala como visitante.", + "Reconnecting…": "Reconectando…", + "Seat": "Rol", + "This whisper room has ended. Sending is disabled.": "Esta sala de susurro ha finalizado. No se pueden enviar mensajes.", + "Trainee": "Terapeuta en prácticas", + "Trainee message": "Mensaje del terapeuta en prácticas", + "Visitor": "Visitante", + "Visitor message": "Mensaje del visitante", + "Waiting…": "Esperando…", + "You are on Trainee — complete step 1 as Visitor first if there is no room yet.": "Estás en el rol de terapeuta en prácticas; si aún no hay una sala, completa primero el paso 1 como visitante.", + "You are on Visitor — start with step 1.": "Estás en el rol de visitante; comienza por el paso 1.", + "You can still copy the room id.": "Aún puedes copiar el ID de la sala.", + "on Trainee, end the room for a debrief.": "como terapeuta en prácticas, finaliza la sala para hacer una puesta en común.", + "room": "sala", + "send the first message to get a room id.": "envía el primer mensaje para obtener el ID de una sala.", + "switch seats and send counselor lines. Private notes show with a lock.": "cambia de rol y envía intervenciones del terapeuta. Las notas privadas aparecen con un candado.", + "trainee": "terapeuta en prácticas", + "visitor": "visitante", + "whisper": "susurro", + "you": "tú", + "Session {{id}}": "Sesión {{id}}" +} diff --git a/web/locales/es/common.json b/web/locales/es/common.json new file mode 100644 index 0000000000..5c0f9de67a --- /dev/null +++ b/web/locales/es/common.json @@ -0,0 +1,9 @@ +{ + "language.english": "Inglés", + "language.chinese": "Chino simplificado", + "language.spanishSpain": "Español (España)", + "common.loading": "Cargando...", + "common.save": "Guardar", + "common.cancel": "Cancelar", + "common.close": "Cerrar" +} diff --git a/web/locales/zh/app.json b/web/locales/zh/app.json index a00a81de49..d02152eaea 100644 --- a/web/locales/zh/app.json +++ b/web/locales/zh/app.json @@ -1,6 +1,7 @@ { "language.english": "English", "language.chinese": "中文", + "language.spanishSpain": "西班牙语(西班牙)", "Start": "开始", "Learn": "学习", "Research": "研究", diff --git a/web/locales/zh/common.json b/web/locales/zh/common.json index dbc162d501..5bb1da3b69 100644 --- a/web/locales/zh/common.json +++ b/web/locales/zh/common.json @@ -1,6 +1,7 @@ { "language.english": "English", "language.chinese": "中文", + "language.spanishSpain": "西班牙语(西班牙)", "common.loading": "加载中...", "common.save": "保存", "common.cancel": "取消", diff --git a/web/package.json b/web/package.json index e163b4464a..492fa1a060 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "node ./scripts/build.mjs", "start": "next start", "lint": "eslint .", - "test:node": "node ./scripts/run-node-tests.mjs", + "test:node": "node ./scripts/i18n_parity.mjs && node ./scripts/run-node-tests.mjs", "perf:check": "node ./scripts/route_budgets.mjs", "build:perf": "npm run build && npm run perf:check", "i18n:parity": "node ./scripts/i18n_parity.mjs", diff --git a/web/scripts/i18n_parity.mjs b/web/scripts/i18n_parity.mjs index 046b99cb8e..7db0d0b2e5 100644 --- a/web/scripts/i18n_parity.mjs +++ b/web/scripts/i18n_parity.mjs @@ -1,88 +1,114 @@ -import fs from "node:fs"; -import path from "node:path"; +import fs from 'node:fs' +import path from 'node:path' function listJsonFiles(dir) { - const out = []; + const out = [] for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, ent.name); - if (ent.isDirectory()) out.push(...listJsonFiles(full)); - else if (ent.isFile() && ent.name.endsWith(".json")) out.push(full); + const full = path.join(dir, ent.name) + if (ent.isDirectory()) out.push(...listJsonFiles(full)) + else if (ent.isFile() && ent.name.endsWith('.json')) out.push(full) } - return out; + return out } function loadJson(p) { - return JSON.parse(fs.readFileSync(p, "utf8")); + return JSON.parse(fs.readFileSync(p, 'utf8')) } -function flattenKeys(obj, prefix = "") { - const keys = []; - if (!obj || typeof obj !== "object") return keys; - for (const [k, v] of Object.entries(obj)) { - const next = prefix ? `${prefix}.${k}` : k; - if (v && typeof v === "object" && !Array.isArray(v)) keys.push(...flattenKeys(v, next)); - else keys.push(next); +function flattenValues(obj, prefix = '', out = new Map()) { + if (!obj || typeof obj !== 'object') return out + for (const [key, value] of Object.entries(obj)) { + const next = prefix ? `${prefix}.${key}` : key + if (value && typeof value === 'object' && !Array.isArray(value)) { + flattenValues(value, next, out) + } else { + out.set(next, value) + } } - return keys; + return out +} + +function interpolationFields(value) { + if (typeof value !== 'string') return [] + return [...value.matchAll(/{{\s*([^{}]+?)\s*}}/g)].map(match => match[1].trim()).sort() } function toRel(p, root) { - return path.relative(root, p).replaceAll("\\", "/"); + return path.relative(root, p).replaceAll('\\', '/') } -const webRoot = path.resolve(process.cwd()); -const localesRoot = path.join(webRoot, "locales"); -const enRoot = path.join(localesRoot, "en"); -const zhRoot = path.join(localesRoot, "zh"); +const webRoot = path.resolve(process.cwd()) +const localesRoot = path.join(webRoot, 'locales') +const enRoot = path.join(localesRoot, 'en') -if (!fs.existsSync(enRoot) || !fs.existsSync(zhRoot)) { - console.error(`[i18n:parity] Missing locales roots: ${enRoot} or ${zhRoot}`); - process.exit(2); +if (!fs.existsSync(enRoot)) { + console.error(`[i18n:parity] Missing source locale root: ${enRoot}`) + process.exit(2) } -const enFiles = listJsonFiles(enRoot).map((p) => toRel(p, enRoot)).sort(); -const zhFiles = listJsonFiles(zhRoot).map((p) => toRel(p, zhRoot)).sort(); +const enFiles = listJsonFiles(enRoot) + .map(p => toRel(p, enRoot)) + .sort() +let ok = true +const localeNames = fs + .readdirSync(localesRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name !== 'en') + .map(entry => entry.name) + .sort() -const missingInZh = enFiles.filter((f) => !zhFiles.includes(f)); -const extraInZh = zhFiles.filter((f) => !enFiles.includes(f)); - -let ok = true; -if (missingInZh.length) { - ok = false; - console.error("[i18n:parity] Missing zh files:"); - for (const f of missingInZh) console.error(`- ${f}`); -} -if (extraInZh.length) { - ok = false; - console.error("[i18n:parity] Extra zh files:"); - for (const f of extraInZh) console.error(`- ${f}`); -} +for (const locale of localeNames) { + const localeRoot = path.join(localesRoot, locale) + const localeFiles = listJsonFiles(localeRoot) + .map(p => toRel(p, localeRoot)) + .sort() + const missingFiles = enFiles.filter(file => !localeFiles.includes(file)) + const extraFiles = localeFiles.filter(file => !enFiles.includes(file)) -for (const rel of enFiles) { - if (!zhFiles.includes(rel)) continue; - const enPath = path.join(enRoot, rel); - const zhPath = path.join(zhRoot, rel); - const enJson = loadJson(enPath); - const zhJson = loadJson(zhPath); - const enKeys = new Set(flattenKeys(enJson)); - const zhKeys = new Set(flattenKeys(zhJson)); + if (missingFiles.length || extraFiles.length) { + ok = false + if (missingFiles.length) { + console.error(`[i18n:parity] Missing ${locale} files:`) + for (const file of missingFiles) console.error(`- ${file}`) + } + if (extraFiles.length) { + console.error(`[i18n:parity] Extra ${locale} files:`) + for (const file of extraFiles) console.error(`- ${file}`) + } + } - const missingKeys = [...enKeys].filter((k) => !zhKeys.has(k)).sort(); - const extraKeys = [...zhKeys].filter((k) => !enKeys.has(k)).sort(); + for (const rel of enFiles) { + if (!localeFiles.includes(rel)) continue + const enValues = flattenValues(loadJson(path.join(enRoot, rel))) + const localeValues = flattenValues(loadJson(path.join(localeRoot, rel))) + const enKeys = new Set(enValues.keys()) + const localeKeys = new Set(localeValues.keys()) + const missingKeys = [...enKeys].filter(key => !localeKeys.has(key)).sort() + const extraKeys = [...localeKeys].filter(key => !enKeys.has(key)).sort() - if (missingKeys.length || extraKeys.length) { - ok = false; - console.error(`[i18n:parity] Key mismatch in ${rel}`); - if (missingKeys.length) { - console.error(" Missing zh keys:"); - for (const k of missingKeys) console.error(` - ${k}`); + if (missingKeys.length || extraKeys.length) { + ok = false + console.error(`[i18n:parity] Key mismatch in ${locale}/${rel}`) + if (missingKeys.length) { + console.error(` Missing ${locale} keys:`) + for (const key of missingKeys) console.error(` - ${key}`) + } + if (extraKeys.length) { + console.error(` Extra ${locale} keys:`) + for (const key of extraKeys) console.error(` - ${key}`) + } } - if (extraKeys.length) { - console.error(" Extra zh keys:"); - for (const k of extraKeys) console.error(` - ${k}`); + + for (const key of [...enKeys].filter(item => localeKeys.has(item))) { + const expected = interpolationFields(enValues.get(key)) + const actual = interpolationFields(localeValues.get(key)) + if (JSON.stringify(actual) === JSON.stringify(expected)) continue + ok = false + console.error(`[i18n:parity] Placeholder mismatch in ${locale}/${rel}: ${key}`) + console.error(` Expected: ${expected.join(', ') || '(none)'}`) + console.error(` Actual: ${actual.join(', ') || '(none)'}`) } } } -if (!ok) process.exit(1); -console.log("[i18n:parity] OK"); +if (!ok) process.exit(1) +console.log('[i18n:parity] OK') diff --git a/web/tests/appearance-settings-page.test.ts b/web/tests/appearance-settings-page.test.ts index efcfe4dd58..a525ed0bcc 100644 --- a/web/tests/appearance-settings-page.test.ts +++ b/web/tests/appearance-settings-page.test.ts @@ -74,6 +74,24 @@ test("appearance settings page: exposes every registered code block theme option assert.match(source, /CODE_BLOCK_THEME_OPTIONS\.map/); }); +test("appearance settings page: uses scalable selects for both language preferences", () => { + const source = readAppearancePage(); + + assert.match(source, /function LanguageSelect/); + assert.equal( + source.match(/ { const source = readAppearancePage(); const previewSource = diff --git a/web/tests/language-preferences.test.ts b/web/tests/language-preferences.test.ts index db7e451247..6f2bad1f75 100644 --- a/web/tests/language-preferences.test.ts +++ b/web/tests/language-preferences.test.ts @@ -6,9 +6,11 @@ import { resolveResponseLanguage } from "../context/app-shell-storage"; test("response language remains independent from the interface language", () => { assert.equal(resolveResponseLanguage("zh", "en"), "zh"); assert.equal(resolveResponseLanguage("en", "zh"), "en"); + assert.equal(resolveResponseLanguage("es", "zh"), "es"); }); test("legacy settings inherit the interface language when response language is missing", () => { assert.equal(resolveResponseLanguage(null, "zh"), "zh"); assert.equal(resolveResponseLanguage(undefined, "en"), "en"); + assert.equal(resolveResponseLanguage(undefined, "es"), "es"); }); diff --git a/web/tests/spanish-locale-quality.test.ts b/web/tests/spanish-locale-quality.test.ts new file mode 100644 index 0000000000..6d986e0730 --- /dev/null +++ b/web/tests/spanish-locale-quality.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +function findSpanishCatalog(): string { + let dir = __dirname; + for (let i = 0; i < 8; i++) { + const candidate = path.join(dir, "locales", "es", "app.json"); + if (fs.existsSync(candidate)) return candidate; + dir = path.dirname(dir); + } + throw new Error("could not locate locales/es/app.json"); +} + +const catalog = JSON.parse( + fs.readFileSync(findSpanishCatalog(), "utf8"), +) as Record; + +test("Spanish activity labels describe actions in progress", () => { + assert.equal(catalog["Reading skill"], "Consultando una habilidad"); + assert.equal(catalog["Reading source"], "Consultando una fuente"); + assert.equal(catalog["Writing file"], "Escribiendo un archivo"); + assert.equal(catalog["Listing files"], "Listando archivos"); + assert.equal(catalog["Writing note"], "Escribiendo una nota"); + assert.equal(catalog["Saving memory"], "Guardando en memoria"); + assert.equal(catalog["Brainstorming"], "Lluvia de ideas"); +}); + +test("Spanish catalog rejects known context-free machine translations", () => { + const text = Object.values(catalog).join("\n"); + const forbidden = [ + "TRAPO", + "Máster en Derecho", + "Maestría en Derecho", + "computadora portátil", + "fogonadura", + "rebaja", + "Códice", + "punto final", + "incrustación", + "próxima sucursal", + ]; + for (const fragment of forbidden) { + assert.equal( + text.toLocaleLowerCase("es").includes(fragment.toLocaleLowerCase("es")), + false, + `Found rejected machine translation: ${fragment}`, + ); + } +}); + +test("Spanish UI uses the informal second person consistently", () => { + const text = Object.values(catalog).join("\n"); + for (const formal of [ + "usted", + "haga clic", + "seleccione", + "ingrese", + "compruebe", + "espere", + "libere", + "empiece", + "inténtelo", + "siga", + ]) { + assert.equal( + new RegExp(`\\b${formal}\\b`, "iu").test(text), + false, + `Found formal-address copy: ${formal}`, + ); + } +});