From 48ec2e77d5bd3e12b6b37aec7f02f09644ebc6f5 Mon Sep 17 00:00:00 2001 From: xunilzita Date: Tue, 25 Aug 2026 12:53:50 +0200 Subject: [PATCH] feat: add Spanish (es) locale for Dashboard and CLI Add full Spanish i18n alongside English and Chinese so SuperTask can be used natively by Spanish-speaking operators without losing upgrades. - Dashboard: ES message catalog, language switcher, Accept-Language/cookie - CLI: --lang es / SUPERTASK_LANG=es and Spanish system locale auto-detect - Docs: README.es.md, changelog, operations notes - Tests: CLI language coverage and browser language switch smoke --- CHANGELOG.md | 7 + README.es.md | 240 ++++++++++++++++++++ README.md | 6 +- README.zh-CN.md | 2 +- docs/README.md | 2 +- docs/operations.md | 2 +- src/cli/database-output.ts | 78 ++++++- src/cli/i18n.ts | 13 +- src/cli/index.ts | 280 ++++++++++++------------ src/web/index.tsx | 29 ++- src/web/ui.ts | 339 ++++++++++++++++++++++++++++- tests/browser/dashboard.browser.ts | 4 + tests/cli-language.test.ts | 10 + 13 files changed, 845 insertions(+), 167 deletions(-) create mode 100644 README.es.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 62be8d0..ea393a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### Added +- Dashboard and CLI now support Spanish (`es`) alongside English and Chinese. +- CLI `--lang es` / `SUPERTASK_LANG=es`, and automatic selection from Spanish system locales. +- Dashboard language switcher includes **ES**; `Accept-Language` and `supertask_locale` negotiate `es`. + # Changelog All notable user-facing changes are recorded here. This project follows semantic versioning while it is in the `0.x` development series. diff --git a/README.es.md b/README.es.md new file mode 100644 index 0000000..58cd5d3 --- /dev/null +++ b/README.es.md @@ -0,0 +1,240 @@ +# SuperTask + +

Encola. Programa. Reintenta. Sabe qué ocurrió.

+ +

+ npm version + CI + MIT License +

+ +

+ English | 简体中文 | Español +

+ +SuperTask convierte los comandos puntuales `opencode run` en operaciones durables de agentes. Ofrece a los agentes de OpenCode una cola SQLite persistente, programación, reintentos, control de concurrencia, cancelación segura, historial de ejecución y un panel web local. + +OpenCode puede ejecutar un agente ahora. SuperTask garantiza que el trabajo siga rastreado tras cerrar el terminal, si el proceso falla o si la máquina se reinicia. + +## ¿Por qué SuperTask? + +| Si necesitas... | Usa | +| --- | --- | +| Ejecutar un agente una sola vez | `opencode run` | +| Ejecutar unos pocos comandos fijos a horas fijas | `cron`, `launchd`, `systemd` o GitHub Actions | +| Reiniciar un proceso de larga duración con una programación | PM2 `cron_restart` | +| Gestionar trabajos de agentes variables con estado durable, reintentos, prioridades e historial | **SuperTask** | + +SuperTask no es otro envoltorio sobre cron. El trabajo programado se convierte en una tarea normal de la cola durable, de modo que los trabajos manuales y programados comparten las mismas reglas de concurrencia, reintento, cancelación, dependencia e historial. + +## Qué obtienes + +| Capacidad | Qué significa | +| --- | --- | +| Cola durable | Las tareas y cada ejecución sobreviven a reinicios de proceso y de máquina en SQLite WAL | +| Tres tipos de programación | Cron, retraso de una sola vez e intervalo fijo recurrente | +| Recuperación automática | Presupuesto de reintentos, backoff exponencial, estado dead-letter y reintento manual | +| Ejecución controlada | Concurrencia global, orden por prioridad, dependencias y serialización global por lote | +| Conciencia de proyecto | Cada tarea conserva su directorio de proyecto OpenCode, agente, modelo y variante opcional | +| Manejo seguro de procesos | Cancelación y apagado esperan a que el grupo de procesos Unix gestionado de OpenCode se drene | +| Ejecuciones observables | ID de sesión, comando exacto reproducible, salida del modelo, herramientas, errores y JSONL en bruto | +| Panel local | Crear, programar, inspeccionar, reintentar, cancelar y diagnosticar desde `127.0.0.1` | + +## Inicio rápido en tres minutos + +### 1. Instala una versión exacta + +```bash +VERSION="$(npm view opencode-supertask dist-tags.latest)" +npm install -g "opencode-supertask@$VERSION" +opencode plugin "opencode-supertask@$VERSION" --global --force +``` + +Fijar la versión exacta mantiene alineados el plugin de OpenCode, la CLI global y el Gateway. No lo sustituyas por el nombre del paquete sin versión ni por `@latest` en `opencode.json`. + +### 2. Reinicia OpenCode e inicia el Gateway + +```bash +supertask install # recomendado: arranque con PM2, recuperación ante fallos y rotación de logs +``` + +Para desarrollo en primer plano: + +```bash +supertask gateway +``` + +El plugin nunca instala servicios globales al arrancar OpenCode. La configuración de PM2 solo ocurre cuando ejecutas explícitamente `supertask install`. + +### 3. Pide a OpenCode que cree una tarea + +```text +Crea una SuperTask llamada "Revisar errores de API". +Usa el agente build en este proyecto, reintenta dos veces y ejecútala ahora. +``` + +OpenCode recibe ocho herramientas nativas del plugin `supertask_*`. El directorio del proyecto actual se toma del contexto de herramientas de OpenCode y no se confía en la entrada del modelo. + +### 4. Observa la ejecución + +```bash +supertask status +supertask list --limit 10 +supertask ui +``` + +El panel se abre en . + +## Cómo funciona + +```mermaid +flowchart LR + A[Herramientas OpenCode / CLI / Panel] --> B[Cola de tareas SQLite] + B --> C[Gateway] + C --> D[Worker] + C --> E[Scheduler] + C --> F[Watchdog] + D --> G[opencode run] + G --> H[Historial de ejecución y sesión] +``` + +Un único Gateway posee las transiciones de estado en tiempo de ejecución. Los clientes crean y gestionan trabajo; solo el Gateway marca las ejecuciones como iniciadas, completadas, fallidas, reintentadas o canceladas. + +## Úsalo a tu manera + +### Lenguaje natural en OpenCode + +```text +Ejecuta una revisión de seguridad con el agente build, el modelo provider/model y la variante high. + +Cada día laborable a las 9:00, crea una tarea de informe para este proyecto. + +Muestra las tareas fallidas de este proyecto y reintenta las recuperables. + +Comprueba si el lote "release" se está ejecutando en otro proyecto. +``` + +Herramientas del plugin disponibles: + +```text +supertask_add supertask_schedule supertask_status supertask_retry +supertask_list supertask_get supertask_next supertask_upgrade +``` + +### CLI + +```bash +# Encolar trabajo +supertask add --name "Revisión de seguridad" --agent build \ + --model openai/gpt-5.6-sol --variant xhigh \ + --prompt "Revisar autenticación y autorización" \ + --importance 5 --urgency 4 --max-retries 2 \ + --retry-backoff 30s --timeout 30min + +# Programar trabajo +supertask template add --name "Informe laborable" --agent build \ + --model openai/gpt-5.6-sol --variant high \ + --prompt "Resumir cambios importantes del proyecto" \ + --type cron --cron "0 9 * * 1-5" + +# Inspeccionar y recuperar +supertask status +supertask list --status failed --limit 20 +supertask retry --id 42 +supertask cancel --id 42 +``` + +Ejecuta `supertask --help` o `supertask --help` para la superficie completa de comandos. La ayuda de la CLI y los diagnósticos legibles admiten `auto`, `en`, `es` y `zh-CN`. + +## Panel + +El panel adaptable admite inglés, español y chino, temas claro y oscuro, y cuatro vistas centradas: + +| Página | Propósito | +| --- | --- | +| Cola de tareas | Explorar proyectos, crear/editar tareas, ver prioridades y estado activo, reintentar, cancelar o eliminar con seguridad | +| Tareas programadas | Crear/editar plantillas cron, retrasadas y recurrentes; ejecutar una de inmediato sin saltarse la cola | +| Registros de ejecución | Leer salida estructurada, herramientas, errores, sesiones y el comando histórico exacto | +| Estado del sistema | Inspeccionar la configuración activa, la salud, la concurrencia y el mantenimiento de la base de datos con copia de seguridad previa | + +El selector de proyectos lee la salida real de `opencode agent list` y `opencode models --verbose` del directorio elegido, de modo que los formularios solo ofrecen modelos disponibles localmente, las variantes declaradas de cada modelo y agentes directamente ejecutables. Dejar la variante en su valor predeterminado omite `--variant` y sigue la configuración del agente/modelo. + +## Fiabilidad sin rodeos + +- SQLite `BEGIN IMMEDIATE` protege el bloqueo de un solo Gateway y la serialización global por lote. +- La selección de candidatos y la transición a `running` ocurren en una sola transacción inmediata, de modo que ediciones concurrentes no pueden alterar una tarea reclamada. +- Cada ejecución gestionada tiene una identidad de lanzador única y un grupo de procesos Unix aislado. +- Una ejecución solo se cierra después de que el lanzador demuestra que todo el grupo de procesos se ha drenado. +- La contención de procesos termina en ese grupo: los descendientes que llaman deliberadamente a `setsid()` o arrancan como daemons desacoplados deben gestionar su propio ciclo de vida. +- El apagado y la cancelación fallan de forma cerrada cuando no se puede demostrar la propiedad del proceso. +- `supertask doctor` verifica OpenCode, el plugin fijado efectivo, la caché, la CLI, el paquete del Gateway, el bloqueo de listo, SQLite, el panel y el entorno PM2. +- Vaciar y restaurar la base de datos son operaciones transaccionales, con copia de seguridad previa, coherentes con WAL y rechazan el trabajo activo. + +Las garantías detalladas y las reglas de recuperación están en [Architecture](docs/architecture.md) y [Operations and Troubleshooting](docs/operations.md). + +## Actualizar y diagnosticar + +```bash +supertask upgrade # actualizar solo cuando las versiones o componentes hayan divergido +supertask upgrade --force # reinstalar la versión actual, refrescar el entorno y reiniciar +supertask doctor +supertask doctor --smoke --smoke-agent build --smoke-model provider/model --smoke-variant high +``` + +Cuando todos los componentes ya coinciden con npm `latest`, la actualización normal no hace nada y no reinicia el Gateway. Los diagnósticos smoke realizan una llamada real al modelo; el `doctor` ordinario no. + +## Requisitos + +- OpenCode +- Bun 1.1.45 o posterior +- Node.js/npm para el flujo documentado de instalación y actualización +- macOS o Linux para la ejecución de tareas del Gateway + +La ejecución del Worker en Windows permanece deshabilitada hasta que el aislamiento con OS Job Object pueda ofrecer un drenado gestionado equivalente y una prueba recuperable. La ejecución de la cola no requiere PM2 cuando el Gateway corre en primer plano. + +## Instalar desde el código fuente + +```bash +git clone https://github.com/vbgate/opencode-supertask.git +cd opencode-supertask +bun install +bun run build +``` + +Apunta OpenCode al archivo del plugin construido: + +```json +{ + "plugin": [ + "file:///home/user/src/opencode-supertask/dist/plugin/supertask.js" + ] +} +``` + +Luego reinicia OpenCode y ejecuta `bun run gateway` desde el repositorio. + +## Documentación + +- [Operaciones y resolución de problemas](docs/operations.md) +- [Arquitectura y decisiones actuales](docs/architecture.md) +- [Changelog](CHANGELOG.md) +- [Índice de documentación](docs/README.md) +- [Reglas para contribuidores y agentes](AGENTS.md) + +## Desarrollo + +```bash +bun install --frozen-lockfile +bun test +bun run typecheck +bun run typecheck:tests +bun run lint +bun run test:coverage +bun run test:browser +bun run build +bun run package:smoke +``` + +## Licencia + +MIT diff --git a/README.md b/README.md index e30208c..6ffd1e5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- English | 简体中文 + English | 简体中文 | Español

SuperTask turns one-off `opencode run` commands into durable Agent operations. It gives OpenCode agents a persistent SQLite queue, scheduling, retries, concurrency control, safe cancellation, execution history, and a local Web Dashboard. @@ -144,11 +144,11 @@ supertask retry --id 42 supertask cancel --id 42 ``` -Run `supertask --help` or `supertask --help` for the complete command surface. CLI help and human-readable diagnostics support `auto`, `en`, and `zh-CN`. +Run `supertask --help` or `supertask --help` for the complete command surface. CLI help and human-readable diagnostics support `auto`, `en`, `es`, and `zh-CN`. ## Dashboard -The responsive Dashboard supports English and Chinese, light and dark themes, and four focused views: +The responsive Dashboard supports English, Spanish, and Chinese, light and dark themes, and four focused views: | Page | Purpose | | --- | --- | diff --git a/README.zh-CN.md b/README.zh-CN.md index 86b26ff..7cd25bc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,7 +144,7 @@ supertask retry --id 42 supertask cancel --id 42 ``` -运行 `supertask --help` 或 `supertask <命令> --help` 查看完整参数。CLI 帮助和人类可读诊断支持 `auto`、`zh-CN` 和 `en`。 +运行 `supertask --help` 或 `supertask <命令> --help` 查看完整参数。CLI 帮助和人类可读诊断支持 `auto`、`zh-CN`、`en` 和 `es`。 ## Web 控制台 diff --git a/docs/README.md b/docs/README.md index 6d2f3df..c95d0dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,7 @@ | 文档 | 用途 | 权威范围 | |---|---|---| -| [README](../README.md) / [简体中文](../README.zh-CN.md) | 产品定位、安装、快速开始、CLI 摘要 | 面向使用者的双语入口 | +| [README](../README.md) / [简体中文](../README.zh-CN.md) / [Español](../README.es.md) | 产品定位、安装、快速开始、CLI 摘要 | 面向使用者的多语言入口 | | [CHANGELOG](../CHANGELOG.md) | 各稳定版本与未发布变更 | 面向升级与发布核对 | | [当前架构与决策](./architecture.md) | 组件边界、执行链路、状态语义、架构取舍 | 当前源码架构的权威说明 | | [运行与排障手册](./operations.md) | 启停、PM2、配置、重试、备份、故障定位 | 当前运行行为的权威说明 | diff --git a/docs/operations.md b/docs/operations.md index b1d75ac..1780f22 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -89,7 +89,7 @@ PM2 生命周期操作和 macOS supervisor 始终先获取 `PM2_HOME/supertask-g CLI 的任务/模板 ID、优先级、重试次数和列表数量均按完整十进制整数解析;带尾随字符、小数、越界值和未知任务状态会返回非零退出码,不会再由 `parseInt` 静默截断。 -CLI 帮助、`doctor` 和数据库维护的交互式摘要支持 `auto | zh-CN | en`。默认 `auto` 根据 `LC_ALL`、`LC_MESSAGES`、`LANG` 选择,非中文 locale 回退英文;可用全局 `--lang` 或 `SUPERTASK_LANG` 覆盖。JSON 字段和底层诊断错误保持原样,不因界面语言变化而破坏 Agent、管道和脚本解析。 +CLI 帮助、`doctor` 和数据库维护的交互式摘要支持 `auto | zh-CN | en | es`。默认 `auto` 根据 `LC_ALL`、`LC_MESSAGES`、`LANG` 选择,中文/西班牙文 locale 分别选择对应语言,其余回退英文;可用全局 `--lang` 或 `SUPERTASK_LANG` 覆盖。JSON 字段和底层诊断错误保持原样,不因界面语言变化而破坏 Agent、管道和脚本解析。 ## 完整配置 diff --git a/src/cli/database-output.ts b/src/cli/database-output.ts index 784f8a1..d87d1e7 100644 --- a/src/cli/database-output.ts +++ b/src/cli/database-output.ts @@ -48,20 +48,41 @@ function formatCounts(counts: DatabaseCounts, locale: CliLocale): string { locale, `任务 ${counts.tasks} · 执行记录 ${counts.taskRuns} · 定时任务模板 ${counts.taskTemplates}`, `tasks ${counts.tasks} · runs ${counts.taskRuns} · scheduled templates ${counts.taskTemplates}`, + `tareas ${counts.tasks} · ejecuciones ${counts.taskRuns} · plantillas programadas ${counts.taskTemplates}`, ); } function formatGateway(gateway: GatewayMaintenanceReport, locale: CliLocale): string { - if (gateway.restarted) return cliText(locale, '已自动停止、重启并恢复就绪', 'stopped, restarted, and ready'); - if (gateway.keptStopped) return cliText(locale, '已自动停止,按要求保持停止', 'stopped and left stopped as requested'); + if (gateway.restarted) { + return cliText( + locale, + '已自动停止、重启并恢复就绪', + 'stopped, restarted, and ready', + 'detenido, reiniciado y listo', + ); + } + if (gateway.keptStopped) { + return cliText( + locale, + '已自动停止,按要求保持停止', + 'stopped and left stopped as requested', + 'detenido y dejado detenido según lo solicitado', + ); + } if (!gateway.wasRunning) { return cliText( locale, '无需自动停启(未发现匹配当前数据库的 PM2 Gateway)', 'no matching PM2 Gateway was running', + 'no había un Gateway PM2 coincidente en ejecución', ); } - return cliText(locale, '原 Gateway 未恢复运行', 'the previous Gateway was not restored'); + return cliText( + locale, + '原 Gateway 未恢复运行', + 'the previous Gateway was not restored', + 'el Gateway anterior no se restauró', + ); } function formatCheck(result: DatabaseCheckResult, locale: CliLocale): string { @@ -84,6 +105,25 @@ function formatCheck(result: DatabaseCheckResult, locale: CliLocale): string { } return lines.join('\n'); } + if (locale === 'es') { + const lines = [ + result.ok ? '✓ Comprobación de la base de datos correcta' : '✗ Comprobación de la base de datos fallida', + '', + `Base de datos: ${result.path}`, + `Tamaño: ${formatBytes(result.sizeBytes)}`, + `Modo de journal: ${result.journalMode}`, + `Recuentos: ${formatCounts(result.counts, locale)}`, + `En ejecución: tareas ${result.runningTasks} · ejecuciones ${result.runningRuns}`, + ]; + if (!result.ok) { + lines.push( + `Integridad: ${result.integrityMessages.join('; ') || 'sin resultado'}`, + `Violaciones de clave foránea: ${result.foreignKeyViolations}`, + `Tablas ausentes: ${result.missingTables.join(', ') || 'ninguna'}`, + ); + } + return lines.join('\n'); + } const lines = [ result.ok ? '✓ 数据库检查通过' : '✗ 数据库检查未通过', '', @@ -123,6 +163,16 @@ function formatHuman( `Integrity: ${backup.check.ok ? 'passed' : 'failed'}`, ].join('\n'); } + if (locale === 'es') { + return [ + '✓ Copia de seguridad de la base de datos completada', + '', + `Copia: ${backup.path}`, + `Tamaño: ${formatBytes(backup.sizeBytes)}`, + `Recuentos: ${formatCounts(backup.check.counts, locale)}`, + `Integridad: ${backup.check.ok ? 'correcta' : 'fallida'}`, + ].join('\n'); + } return [ '✓ 数据库备份完成', '', @@ -144,6 +194,16 @@ function formatHuman( `Integrity: ${cleared.check.ok ? 'passed' : 'failed'}`, ].join('\n'); } + if (locale === 'es') { + return [ + '✓ Base de datos vaciada de forma segura', + '', + `Eliminado: ${formatCounts(cleared.deleted, locale)}`, + `Copia de seguridad de seguridad: ${cleared.backupPath}`, + `Gateway: ${formatGateway(cleared.gateway, locale)}`, + `Integridad: ${cleared.check.ok ? 'correcta' : 'fallida'}`, + ].join('\n'); + } return [ '✓ 数据库已安全清空', '', @@ -167,6 +227,18 @@ function formatHuman( `Integrity: ${restored.check.ok ? 'passed' : 'failed'}`, ].join('\n'); } + if (locale === 'es') { + return [ + '✓ Restauración de la base de datos completada', + '', + `Origen: ${restored.sourcePath}`, + `Copia de seguridad previa a la restauración: ${restored.safetyBackupPath}`, + `Estado de ejecución recuperado: tareas ${restored.recoveredRunningTasks} · ejecuciones ${restored.closedRunningRuns}`, + `Recuentos: ${formatCounts(restored.check.counts, locale)}`, + `Gateway: ${formatGateway(restored.gateway, locale)}`, + `Integridad: ${restored.check.ok ? 'correcta' : 'fallida'}`, + ].join('\n'); + } return [ '✓ 数据库恢复完成', '', diff --git a/src/cli/i18n.ts b/src/cli/i18n.ts index a784efa..65e2d7f 100644 --- a/src/cli/i18n.ts +++ b/src/cli/i18n.ts @@ -1,4 +1,4 @@ -export type CliLocale = 'zh-CN' | 'en'; +export type CliLocale = 'zh-CN' | 'en' | 'es'; function requestedLanguage(argv: string[]): string | undefined { for (let index = 2; index < argv.length; index += 1) { @@ -15,12 +15,17 @@ export function resolveCliLocale( ): CliLocale { const requested = requestedLanguage(argv) ?? env.SUPERTASK_LANG ?? 'auto'; if (requested === 'zh' || requested === 'zh-CN') return 'zh-CN'; + if (requested === 'es' || requested === 'es-ES' || requested === 'es-MX') return 'es'; if (requested === 'en') return 'en'; const systemLocale = env.LC_ALL || env.LC_MESSAGES || env.LANG || ''; - return /^zh(?:[_-]|$)/i.test(systemLocale) ? 'zh-CN' : 'en'; + if (/^zh(?:[_-]|$)/i.test(systemLocale)) return 'zh-CN'; + if (/^es(?:[_-]|$)/i.test(systemLocale)) return 'es'; + return 'en'; } -export function cliText(locale: CliLocale, zh: string, en: string): string { - return locale === 'zh-CN' ? zh : en; +export function cliText(locale: CliLocale, zh: string, en: string, es: string = en): string { + if (locale === 'zh-CN') return zh; + if (locale === 'es') return es; + return en; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 653a515..130bedf 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -28,7 +28,7 @@ import { cliText, resolveCliLocale } from './i18n'; import { runDoctorSmoke, type DoctorSmokeResult } from './doctor-smoke'; const cliLocale = resolveCliLocale(); -const t = (zh: string, en: string): string => cliText(cliLocale, zh, en); +const t = (zh: string, en: string, es?: string): string => cliText(cliLocale, zh, en, es); async function withDb( fn: () => Promise, @@ -67,13 +67,13 @@ const program = new Command(); program .name('supertask') - .description(t('面向 OpenCode Agent 的持久化任务队列与定时任务系统', 'Durable task queue and scheduler for OpenCode agents')) - .addOption(new Option('--lang ', t('CLI 语言:auto / zh-CN / en', 'CLI language: auto / zh-CN / en')) - .choices(['auto', 'zh-CN', 'en']) + .description(t('面向 OpenCode Agent 的持久化任务队列与定时任务系统', 'Durable task queue and scheduler for OpenCode agents', 'Cola de tareas durable y programador para agentes de OpenCode')) + .addOption(new Option('--lang ', t('CLI 语言:auto / zh-CN / en / es', 'CLI language: auto / zh-CN / en / es', 'idioma de la CLI: auto / zh-CN / en / es')) + .choices(['auto', 'zh-CN', 'en', 'es']) .default('auto')) - .helpOption('-h, --help', t('显示帮助', 'display help for command')) - .addHelpCommand('help [command]', t('显示命令帮助', 'display help for command')) - .version(getPackageVersion(), '-V, --version', t('显示版本号', 'output the version number')); + .helpOption('-h, --help', t('显示帮助', 'display help for command', 'mostrar la ayuda del comando')) + .addHelpCommand('help [command]', t('显示命令帮助', 'display help for command', 'mostrar la ayuda del comando')) + .version(getPackageVersion(), '-V, --version', t('显示版本号', 'output the version number', 'mostrar el número de versión')); if (cliLocale === 'zh-CN') { program.configureOutput({ @@ -87,21 +87,21 @@ if (cliLocale === 'zh-CN') { program .command('add') - .description(t('创建新任务', 'create a queued task')) - .requiredOption('-n, --name ', t('任务名称', 'task name')) - .requiredOption('-a, --agent ', t('主 Agent 名称', 'primary agent name')) - .requiredOption('-p, --prompt ', t('提示词', 'prompt')) - .option('-m, --model ', t('模型', 'model')) - .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh')) - .option('-c, --category ', t('分类', 'category'), 'general') - .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)'), '3') - .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)'), '3') - .option('-b, --batch ', t('批次 ID', 'batch ID')) - .option('-d, --depends ', t('依赖的任务 ID', 'dependency task ID')) - .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt'), '3') - .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min'), '30s') - .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h')) - .option('-w, --cwd ', t('(已废弃) 系统会自动记录提交时的当前目录', '(deprecated) the submission directory is recorded automatically')) + .description(t('创建新任务', 'create a queued task', 'crear una tarea en cola')) + .requiredOption('-n, --name ', t('任务名称', 'task name', 'nombre de la tarea')) + .requiredOption('-a, --agent ', t('主 Agent 名称', 'primary agent name', 'nombre del agente principal')) + .requiredOption('-p, --prompt ', t('提示词', 'prompt', 'prompt')) + .option('-m, --model ', t('模型', 'model', 'modelo')) + .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh')) + .option('-c, --category ', t('分类', 'category', 'categoría'), 'general') + .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)', 'importancia (1-5)'), '3') + .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)', 'urgencia (1-5)'), '3') + .option('-b, --batch ', t('批次 ID', 'batch ID', 'ID de lote')) + .option('-d, --depends ', t('依赖的任务 ID', 'dependency task ID', 'ID de la tarea dependiente')) + .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt', 'reintentos permitidos tras el primer intento'), '3') + .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min'), '30s') + .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h', 'timeout estricto, p. ej. 30min / 2h')) + .option('-w, --cwd ', t('(已废弃) 系统会自动记录提交时的当前目录', '(deprecated) the submission directory is recorded automatically', '(obsoleto) el directorio de envío se registra automáticamente')) .action(async (options) => withDb(async () => { const submitCwd = process.cwd(); const retryBackoffMs = parseDuration(options.retryBackoff); @@ -130,23 +130,23 @@ program program .command('edit') - .description(t('修改当前项目中尚未完成的任务', 'edit an unfinished task in the current project')) - .requiredOption('--id ', t('任务 ID', 'task ID')) - .option('-n, --name ', t('任务名称', 'task name')) - .option('-a, --agent ', t('Agent 名称', 'agent name')) - .option('-m, --model ', t('模型', 'model')) - .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh')) - .option('--clear-variant', t('清空任务级 variant,跟随 Agent / 模型默认值', 'clear the task variant and use the Agent / model default')) - .option('-p, --prompt ', t('提示词', 'prompt')) - .option('-c, --category ', t('分类', 'category')) - .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)')) - .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)')) - .option('-b, --batch ', t('批次 ID', 'batch ID')) - .option('--clear-batch', t('清空批次 ID', 'clear the batch ID')) - .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt')) - .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min')) - .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h')) - .option('--clear-timeout', t('清空任务级超时,改用 Gateway 默认值', 'clear the task timeout and use the Gateway default')) + .description(t('修改当前项目中尚未完成的任务', 'edit an unfinished task in the current project', 'editar una tarea no finalizada del proyecto actual')) + .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea')) + .option('-n, --name ', t('任务名称', 'task name', 'nombre de la tarea')) + .option('-a, --agent ', t('Agent 名称', 'agent name', 'nombre del agente')) + .option('-m, --model ', t('模型', 'model', 'modelo')) + .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh')) + .option('--clear-variant', t('清空任务级 variant,跟随 Agent / 模型默认值', 'clear the task variant and use the Agent / model default', 'borrar la variante de la tarea y usar el valor predeterminado del agente / modelo')) + .option('-p, --prompt ', t('提示词', 'prompt', 'prompt')) + .option('-c, --category ', t('分类', 'category', 'categoría')) + .option('-i, --importance ', t('重要程度 (1-5)', 'importance (1-5)', 'importancia (1-5)')) + .option('-u, --urgency ', t('紧急程度 (1-5)', 'urgency (1-5)', 'urgencia (1-5)')) + .option('-b, --batch ', t('批次 ID', 'batch ID', 'ID de lote')) + .option('--clear-batch', t('清空批次 ID', 'clear the batch ID', 'borrar el ID de lote')) + .option('--max-retries ', t('首次执行之外允许的重试次数', 'retries allowed after the first attempt', 'reintentos permitidos tras el primer intento')) + .option('--retry-backoff ', t('重试退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min')) + .option('--timeout ', t('任务硬超时,如 30min / 2h', 'hard timeout, e.g. 30min / 2h', 'timeout estricto, p. ej. 30min / 2h')) + .option('--clear-timeout', t('清空任务级超时,改用 Gateway 默认值', 'clear the task timeout and use the Gateway default', 'borrar el timeout de la tarea y usar el predeterminado del Gateway')) .action(async (options) => withDb(async () => { if (options.batch !== undefined && options.clearBatch) { throw new Error('batch 和 clear-batch 不能同时使用'); @@ -194,7 +194,7 @@ program program .command('next') - .description(t('获取下一个待执行的任务', 'show the next runnable task')) + .description(t('获取下一个待执行的任务', 'show the next runnable task', 'mostrar la siguiente tarea ejecutable')) .action(async () => withDb(async () => { const task = await TaskService.next({ cwd: process.cwd() }); if (task) { @@ -217,8 +217,8 @@ program program .command('cancel') - .description(t('取消任务', 'cancel a task')) - .requiredOption('--id ', t('任务 ID', 'task ID')) + .description(t('取消任务', 'cancel a task', 'cancelar una tarea')) + .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea')) .action(async (options) => withDb(async () => { const task = await TaskService.cancel(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() }); if (task) { @@ -230,13 +230,13 @@ program })); const runCommand = new Command('run') - .description(t('管理隔离的执行记录', 'manage quarantined task runs')); + .description(t('管理隔离的执行记录', 'manage quarantined task runs', 'gestionar ejecuciones de tareas en cuarentena')); runCommand .command('abandon') - .description(t('人工关闭已确认不存在遗留进程的旧版无 PID 隔离记录', 'close a legacy no-PID quarantine after confirming no process remains')) - .requiredOption('--id ', t('执行记录 run ID', 'run ID')) - .option('--confirm ', t('危险操作确认,必须填写 ABANDON', 'confirmation word; must be ABANDON')) + .description(t('人工关闭已确认不存在遗留进程的旧版无 PID 隔离记录', 'close a legacy no-PID quarantine after confirming no process remains', 'cerrar una cuarentena legacy sin PID tras confirmar que no queda ningún proceso')) + .requiredOption('--id ', t('执行记录 run ID', 'run ID', 'ID de la ejecución')) + .option('--confirm ', t('危险操作确认,必须填写 ABANDON', 'confirmation word; must be ABANDON', 'palabra de confirmación; debe ser ABANDON')) .action(async (options: { id: string; confirm?: string }) => withDb(async () => { if (options.confirm !== 'ABANDON') { throw new Error('关闭旧版隔离 run 必须显式传入 --confirm ABANDON'); @@ -251,9 +251,9 @@ program.addCommand(runCommand); program .command('retry') - .description(t('重试失败的任务', 'retry failed tasks')) - .option('--id ', t('任务 ID', 'task ID')) - .option('-b, --batch ', t('批次 ID(批量重试)', 'batch ID for bulk retry')) + .description(t('重试失败的任务', 'retry failed tasks', 'reintentar tareas fallidas')) + .option('--id ', t('任务 ID', 'task ID', 'ID de la tarea')) + .option('-b, --batch ', t('批次 ID(批量重试)', 'batch ID for bulk retry', 'ID de lote para reintento masivo')) .action(async (options) => withDb(async () => { if (options.id) { const task = await TaskService.retry(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() }); @@ -274,8 +274,8 @@ program program .command('status') - .description(t('查看任务统计', 'show task counts')) - .option('-b, --batch ', t('按批次统计', 'filter counts by batch')) + .description(t('查看任务统计', 'show task counts', 'mostrar recuentos de tareas')) + .option('-b, --batch ', t('按批次统计', 'filter counts by batch', 'filtrar recuentos por lote')) .action(async (options) => withDb(async () => { const stats = await TaskService.stats({ batchId: options.batch, cwd: process.cwd() }); console.log(JSON.stringify(stats, null, 2)); @@ -283,11 +283,11 @@ program program .command('list') - .description(t('列出任务', 'list tasks')) - .option('-s, --status ', t('按状态筛选', 'filter by status')) - .option('-b, --batch ', t('按批次筛选', 'filter by batch')) - .option('-c, --category ', t('按分类筛选', 'filter by category')) - .option('-l, --limit ', t('限制数量', 'maximum rows'), '20') + .description(t('列出任务', 'list tasks', 'listar tareas')) + .option('-s, --status ', t('按状态筛选', 'filter by status', 'filtrar por estado')) + .option('-b, --batch ', t('按批次筛选', 'filter by batch', 'filtrar por lote')) + .option('-c, --category ', t('按分类筛选', 'filter by category', 'filtrar por categoría')) + .option('-l, --limit ', t('限制数量', 'maximum rows', 'filas máximas'), '20') .action(async (options) => withDb(async () => { const tasks = await TaskService.list({ status: parseTaskStatus(options.status), @@ -301,8 +301,8 @@ program program .command('get') - .description(t('获取单个任务详情', 'show one task')) - .requiredOption('--id ', t('任务 ID', 'task ID')) + .description(t('获取单个任务详情', 'show one task', 'mostrar una tarea')) + .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea')) .action(async (options) => withDb(async () => { const task = await TaskService.getById(parsePositiveInteger(options.id, 'id'), { cwd: process.cwd() }); if (task) { @@ -315,8 +315,8 @@ program program .command('delete') - .description(t('删除任务', 'delete a task')) - .requiredOption('--id ', t('任务 ID', 'task ID')) + .description(t('删除任务', 'delete a task', 'eliminar una tarea')) + .requiredOption('--id ', t('任务 ID', 'task ID', 'ID de la tarea')) .action(async (options) => withDb(async () => { const id = parsePositiveInteger(options.id, 'id'); const deleted = await TaskService.delete(id, { cwd: process.cwd() }); @@ -325,27 +325,27 @@ program program .command('template') - .description(t('管理定时任务模板', 'manage scheduled task templates')) + .description(t('管理定时任务模板', 'manage scheduled task templates', 'gestionar plantillas de tareas programadas')) .addCommand( new Command('add') - .description(t('创建定时任务模板', 'create a scheduled task template')) - .requiredOption('-n, --name ', t('模板名称', 'template name')) - .requiredOption('-a, --agent ', t('Agent 名称', 'agent name')) - .requiredOption('-p, --prompt ', t('提示词', 'prompt')) - .requiredOption('-t, --type ', t('定时类型:cron/delayed/recurring', 'schedule type: cron/delayed/recurring')) - .option('--cron ', t('cron 表达式(cron 类型必填)', 'cron expression (required for cron)')) - .option('--delay ', t('延迟时间(delayed 必填),如 30s / 5min / 1h / 2d', 'delay (required for delayed), e.g. 30s / 5min / 1h / 2d')) - .option('--interval ', t('循环间隔(recurring 必填),如 1h / 30min / 5s', 'interval (required for recurring), e.g. 1h / 30min / 5s')) - .option('-m, --model ', t('模型', 'model')) - .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh')) - .option('-c, --category ', t('分类', 'category'), 'general') - .option('-i, --importance ', t('重要程度 1-5', 'importance 1-5'), '3') - .option('-u, --urgency ', t('紧急程度 1-5', 'urgency 1-5'), '3') - .option('-b, --batch ', t('模板生成任务的批次 ID', 'batch ID for generated tasks')) - .option('--max-instances ', t('自动定时任务的活跃实例上限(立即触发不受限)', 'active instance limit for automatic scheduling (Run now is unrestricted)'), '1') - .option('--max-retries ', t('最大重试次数', 'maximum retries'), '3') - .option('--retry-backoff ', t('退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min'), '30s') - .option('--timeout ', t('每次任务硬超时,如 30min / 2h', 'hard timeout per task, e.g. 30min / 2h')) + .description(t('创建定时任务模板', 'create a scheduled task template', 'crear una plantilla de tarea programada')) + .requiredOption('-n, --name ', t('模板名称', 'template name', 'nombre de la plantilla')) + .requiredOption('-a, --agent ', t('Agent 名称', 'agent name', 'nombre del agente')) + .requiredOption('-p, --prompt ', t('提示词', 'prompt', 'prompt')) + .requiredOption('-t, --type ', t('定时类型:cron/delayed/recurring', 'schedule type: cron/delayed/recurring', 'tipo de programación: cron/delayed/recurring')) + .option('--cron ', t('cron 表达式(cron 类型必填)', 'cron expression (required for cron)', 'expresión cron (obligatoria para cron)')) + .option('--delay ', t('延迟时间(delayed 必填),如 30s / 5min / 1h / 2d', 'delay (required for delayed), e.g. 30s / 5min / 1h / 2d', 'retraso (obligatorio para delayed), p. ej. 30s / 5min / 1h / 2d')) + .option('--interval ', t('循环间隔(recurring 必填),如 1h / 30min / 5s', 'interval (required for recurring), e.g. 1h / 30min / 5s', 'intervalo (obligatorio para recurring), p. ej. 1h / 30min / 5s')) + .option('-m, --model ', t('模型', 'model', 'modelo')) + .option('--variant ', t('模型 variant,如 high / xhigh', 'model variant, e.g. high / xhigh', 'variante del modelo, p. ej. high / xhigh')) + .option('-c, --category ', t('分类', 'category', 'categoría'), 'general') + .option('-i, --importance ', t('重要程度 1-5', 'importance 1-5', 'importancia 1-5'), '3') + .option('-u, --urgency ', t('紧急程度 1-5', 'urgency 1-5', 'urgencia 1-5'), '3') + .option('-b, --batch ', t('模板生成任务的批次 ID', 'batch ID for generated tasks', 'ID de lote para las tareas generadas')) + .option('--max-instances ', t('自动定时任务的活跃实例上限(立即触发不受限)', 'active instance limit for automatic scheduling (Run now is unrestricted)', 'límite de instancias activas para la programación automática (Ejecutar ahora no tiene límite)'), '1') + .option('--max-retries ', t('最大重试次数', 'maximum retries', 'reintentos máximos'), '3') + .option('--retry-backoff ', t('退避基础间隔,如 30s / 5min', 'retry backoff base, e.g. 30s / 5min', 'base de espera entre reintentos, p. ej. 30s / 5min'), '30s') + .option('--timeout ', t('每次任务硬超时,如 30min / 2h', 'hard timeout per task, e.g. 30min / 2h', 'timeout estricto por tarea, p. ej. 30min / 2h')) .action(async (options) => withDb(async () => { let intervalMs: number | null = null; let runAt: number | null = null; @@ -397,7 +397,7 @@ program ) .addCommand( new Command('list') - .description(t('列出定时任务模板', 'list scheduled task templates')) + .description(t('列出定时任务模板', 'list scheduled task templates', 'listar plantillas de tareas programadas')) .action(async () => withDb(async () => { const templates = await TaskTemplateService.list(); console.log(JSON.stringify(templates, null, 2)); @@ -405,8 +405,8 @@ program ) .addCommand( new Command('enable') - .description(t('启用模板', 'enable a template')) - .requiredOption('--id ', t('模板 ID', 'template ID')) + .description(t('启用模板', 'enable a template', 'activar una plantilla')) + .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla')) .action(async (options) => withDb(async () => { const tmpl = await TaskTemplateService.enable(parsePositiveInteger(options.id, 'id')); if (tmpl) { @@ -419,8 +419,8 @@ program ) .addCommand( new Command('disable') - .description(t('禁用模板', 'disable a template')) - .requiredOption('--id ', t('模板 ID', 'template ID')) + .description(t('禁用模板', 'disable a template', 'desactivar una plantilla')) + .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla')) .action(async (options) => withDb(async () => { const tmpl = await TaskTemplateService.disable(parsePositiveInteger(options.id, 'id')); if (tmpl) { @@ -433,8 +433,8 @@ program ) .addCommand( new Command('delete') - .description(t('删除模板', 'delete a template')) - .requiredOption('--id ', t('模板 ID', 'template ID')) + .description(t('删除模板', 'delete a template', 'eliminar una plantilla')) + .requiredOption('--id ', t('模板 ID', 'template ID', 'ID de la plantilla')) .action(async (options) => withDb(async () => { const id = parsePositiveInteger(options.id, 'id'); const deleted = await TaskTemplateService.delete(id); @@ -443,12 +443,12 @@ program ); const databaseCommand = new Command('db') - .description(t('数据库检查、备份、清空与恢复', 'check, back up, clear, and restore the database')); + .description(t('数据库检查、备份、清空与恢复', 'check, back up, clear, and restore the database', 'comprobar, respaldar, vaciar y restaurar la base de datos')); databaseCommand .command('check') - .description(t('检查数据库完整性、外键和业务表统计', 'check integrity, foreign keys, and table counts')) - .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)')) + .description(t('检查数据库完整性、外键和业务表统计', 'check integrity, foreign keys, and table counts', 'comprobar integridad, claves foráneas y recuentos de tablas')) + .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)')) .action(async (options: { json?: boolean }) => withDb(async () => { const result = DatabaseMaintenanceService.check(); console.log(renderDatabaseResult('check', result, { forceJson: options.json, locale: cliLocale })); @@ -457,9 +457,9 @@ databaseCommand databaseCommand .command('backup') - .description(t('创建经过完整性校验的一致性备份', 'create a consistent, integrity-checked backup')) - .option('-o, --output ', t('备份文件路径(默认写入数据库目录)', 'backup path (defaults to the database directory)')) - .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)')) + .description(t('创建经过完整性校验的一致性备份', 'create a consistent, integrity-checked backup', 'crear una copia de seguridad coherente y verificada')) + .option('-o, --output ', t('备份文件路径(默认写入数据库目录)', 'backup path (defaults to the database directory)', 'ruta de la copia de seguridad (por defecto el directorio de la base de datos)')) + .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)')) .action(async (options: { output?: string; json?: boolean }) => withDb(async () => { const result = DatabaseMaintenanceService.backup(options.output); console.log(renderDatabaseResult('backup', result, { forceJson: options.json, locale: cliLocale })); @@ -467,10 +467,10 @@ databaseCommand databaseCommand .command('clear') - .description(t('备份后事务性清空任务、执行记录和定时任务模板', 'back up, then transactionally clear tasks, runs, and scheduled templates')) - .option('--confirm ', t('危险操作确认,必须填写 CLEAR', 'confirmation word; must be CLEAR')) - .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped')) - .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)')) + .description(t('备份后事务性清空任务、执行记录和定时任务模板', 'back up, then transactionally clear tasks, runs, and scheduled templates', 'hacer copia de seguridad y vaciar de forma transaccional tareas, ejecuciones y plantillas programadas')) + .option('--confirm ', t('危险操作确认,必须填写 CLEAR', 'confirmation word; must be CLEAR', 'palabra de confirmación; debe ser CLEAR')) + .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped', 'dejar detenido un Gateway gestionado previamente')) + .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)')) .action(async (options: { confirm?: string; keepStopped?: boolean; json?: boolean }) => withDb(async () => { if (options.confirm !== 'CLEAR') { throw new Error('清空数据库必须显式传入 --confirm CLEAR'); @@ -484,11 +484,11 @@ databaseCommand databaseCommand .command('restore') - .description(t('自动备份当前库后,从指定备份恢复数据库', 'back up the live database, then restore from a backup')) - .requiredOption('--from ', t('要恢复的 SQLite 备份文件', 'SQLite backup to restore')) - .option('--confirm ', t('危险操作确认,必须填写 RESTORE', 'confirmation word; must be RESTORE')) - .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped')) - .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)')) + .description(t('自动备份当前库后,从指定备份恢复数据库', 'back up the live database, then restore from a backup', 'hacer copia de seguridad de la base en vivo y restaurar desde una copia')) + .requiredOption('--from ', t('要恢复的 SQLite 备份文件', 'SQLite backup to restore', 'copia de seguridad SQLite a restaurar')) + .option('--confirm ', t('危险操作确认,必须填写 RESTORE', 'confirmation word; must be RESTORE', 'palabra de confirmación; debe ser RESTORE')) + .option('--keep-stopped', t('维护结束后不重启原本由 PM2 管理的 Gateway', 'leave a previously managed Gateway stopped', 'dejar detenido un Gateway gestionado previamente')) + .option('--json', t('强制输出 JSON(非交互调用默认已输出 JSON)', 'force JSON output (already the default when non-interactive)', 'forzar salida JSON (ya es el valor predeterminado fuera de modo interactivo)')) .action(async (options: { from: string; confirm?: string; keepStopped?: boolean; json?: boolean }) => withDb(async () => { if (options.confirm !== 'RESTORE') { throw new Error('恢复数据库必须显式传入 --confirm RESTORE'); @@ -504,7 +504,7 @@ program.addCommand(databaseCommand); program .command('init') - .description(t('初始化 SuperTask(创建配置并执行迁移)', 'initialize SuperTask (create config and run migrations)')) + .description(t('初始化 SuperTask(创建配置并执行迁移)', 'initialize SuperTask (create config and run migrations)', 'inicializar SuperTask (crear configuración y ejecutar migraciones)')) .action(async () => withDb(async () => { const { existsSync, mkdirSync, writeFileSync } = await import('fs'); const { dirname } = await import('path'); @@ -531,7 +531,7 @@ program program .command('migrate') - .description(t('执行数据库迁移', 'run database migrations')) + .description(t('执行数据库迁移', 'run database migrations', 'ejecutar migraciones de la base de datos')) .action(async () => withDb(async () => { const { getDb } = await import('@core/db'); getDb(); @@ -540,7 +540,7 @@ program program .command('gateway') - .description(t('在前台启动 Gateway', 'start the Gateway in the foreground')) + .description(t('在前台启动 Gateway', 'start the Gateway in the foreground', 'iniciar el Gateway en primer plano')) .action(async () => { const { main } = await import('@gateway/index'); await main(); @@ -548,7 +548,7 @@ program program .command('ui') - .description(t('打开 Gateway 内置的 Web 管理界面', 'open the Web Dashboard embedded in the Gateway')) + .description(t('打开 Gateway 内置的 Web 管理界面', 'open the Web Dashboard embedded in the Gateway', 'abrir el panel web integrado en el Gateway')) .action(async () => { const { loadConfig } = await import('@gateway/config'); const cfg = loadConfig(); @@ -563,7 +563,7 @@ program program .command('config') - .description(t('显示当前配置', 'show the current configuration')) + .description(t('显示当前配置', 'show the current configuration', 'mostrar la configuración actual')) .action(async () => { const { loadConfig } = await import('@gateway/config'); const cfg = loadConfig(); @@ -572,14 +572,14 @@ program program .command('doctor') - .description(t('检查 OpenCode、数据库、Gateway、Web 界面和日志轮转', 'diagnose OpenCode, database, Gateway, Dashboard, and log rotation')) - .option('--json', t('强制输出 JSON', 'force JSON output')) - .option('--smoke', t('通过 Gateway 提交一个真实 OpenCode 任务并验证输出', 'queue a real OpenCode task through Gateway and verify its output')) - .option('--smoke-agent ', t('真实冒烟任务使用的 Agent', 'Agent used by the real smoke task'), 'build') - .option('--smoke-model ', t('真实冒烟任务使用的模型;默认跟随 Agent 配置', 'model used by the real smoke task; defaults to the Agent configuration')) - .option('--smoke-variant ', t('真实冒烟任务使用的模型 variant', 'model variant used by the real smoke task')) - .option('--smoke-cwd ', t('真实冒烟任务的项目目录;默认为当前目录', 'project directory for the real smoke task; defaults to the current directory')) - .option('--smoke-timeout ', t('真实冒烟任务等待上限,如 2min / 5min', 'real smoke task timeout, e.g. 2min / 5min'), '3min') + .description(t('检查 OpenCode、数据库、Gateway、Web 界面和日志轮转', 'diagnose OpenCode, database, Gateway, Dashboard, and log rotation', 'diagnosticar OpenCode, la base de datos, el Gateway, el panel y la rotación de logs')) + .option('--json', t('强制输出 JSON', 'force JSON output', 'forzar salida JSON')) + .option('--smoke', t('通过 Gateway 提交一个真实 OpenCode 任务并验证输出', 'queue a real OpenCode task through Gateway and verify its output', 'encolar una tarea real de OpenCode a través del Gateway y verificar su salida')) + .option('--smoke-agent ', t('真实冒烟任务使用的 Agent', 'Agent used by the real smoke task', 'Agente usado por la tarea smoke real'), 'build') + .option('--smoke-model ', t('真实冒烟任务使用的模型;默认跟随 Agent 配置', 'model used by the real smoke task; defaults to the Agent configuration', 'modelo usado por la tarea smoke real; por defecto la configuración del agente')) + .option('--smoke-variant ', t('真实冒烟任务使用的模型 variant', 'model variant used by the real smoke task', 'variante del modelo usada por la tarea smoke real')) + .option('--smoke-cwd ', t('真实冒烟任务的项目目录;默认为当前目录', 'project directory for the real smoke task; defaults to the current directory', 'directorio del proyecto para la tarea smoke real; por defecto el directorio actual')) + .option('--smoke-timeout ', t('真实冒烟任务等待上限,如 2min / 5min', 'real smoke task timeout, e.g. 2min / 5min', 'timeout de la tarea smoke real, p. ej. 2min / 5min'), '3min') .action(async (options: { json?: boolean; smoke?: boolean; @@ -668,8 +668,8 @@ program } if (gateway.startupConfigured === false) { warnings.push(process.platform === 'linux' - ? t('未检测到已启用且包含可恢复 PM2 dump 的 systemd 自启服务', 'No enabled systemd startup service with a recoverable PM2 dump was detected') - : t('未检测到正在运行且包含可恢复 PM2 dump 的 macOS LaunchAgent', 'No running macOS LaunchAgent with a recoverable PM2 dump was detected')); + ? t('未检测到已启用且包含可恢复 PM2 dump 的 systemd 自启服务', 'No enabled systemd startup service with a recoverable PM2 dump was detected', 'No se detectó un servicio de arranque systemd habilitado con un dump PM2 recuperable') + : t('未检测到正在运行且包含可恢复 PM2 dump 的 macOS LaunchAgent', 'No running macOS LaunchAgent with a recoverable PM2 dump was detected', 'No se detectó un LaunchAgent de macOS en ejecución con un dump PM2 recuperable')); } if (gateway.processFound && !gateway.scopeMatches) { warnings.push(t( @@ -685,13 +685,13 @@ program } for (const run of legacyQuarantinedRuns) { const cwdHint = run.taskCwd == null - ? t('(旧任务没有 cwd,请先在 Dashboard 取消)', ' (the legacy task has no cwd; cancel it in the Dashboard first)') - : t(`(在 ${run.taskCwd} 执行)`, ` (run in ${run.taskCwd})`); + ? t('(旧任务没有 cwd,请先在 Dashboard 取消)', ' (the legacy task has no cwd; cancel it in the Dashboard first)', ' (la tarea legacy no tiene cwd; cancélala primero en el panel)') + : t(`(在 ${run.taskCwd} 执行)`, ` (run in ${run.taskCwd})`, ` (ejecutar en ${run.taskCwd})`); const cancel = run.taskStatus === 'cancelled' ? '' - : t(`先${cwdHint} supertask cancel --id ${run.taskId};`, `first${cwdHint}: supertask cancel --id ${run.taskId}; `); + : t(`先${cwdHint} supertask cancel --id ${run.taskId};`, `first${cwdHint}: supertask cancel --id ${run.taskId}; `, `primero${cwdHint}: supertask cancel --id ${run.taskId}; `); const owner = run.ownerAlive - ? t(`owner PID ${run.workerPid} 仍存活,先确认并停止对应进程;`, `owner PID ${run.workerPid} is still alive; confirm and stop it first; `) + ? t(`owner PID ${run.workerPid} 仍存活,先确认并停止对应进程;`, `owner PID ${run.workerPid} is still alive; confirm and stop it first; `, `el PID propietario ${run.workerPid} sigue activo; confírmalo y detenlo primero; `) : ''; warnings.push(t( `旧版隔离 run #${run.runId}:${owner}${cancel}确认没有遗留 OpenCode 进程后执行 supertask run abandon --id ${run.runId} --confirm ABANDON`, @@ -728,7 +728,7 @@ program timeoutMs: smokeTimeoutMs, }); } - if (!smoke.ok) warnings.push(smoke.error ?? t('真实冒烟任务失败', 'Real smoke task failed')); + if (!smoke.ok) warnings.push(smoke.error ?? t('真实冒烟任务失败', 'Real smoke task failed', 'La tarea smoke real falló')); } const ok = opencode.ok && plugin.ok @@ -767,18 +767,18 @@ program console.log(JSON.stringify(report, null, 2)); } else { const mark = (value: boolean) => value ? '✓' : '✗'; - console.log(`SuperTask doctor: ${ok ? t('正常', 'healthy') : t('异常', 'unhealthy')}`); - console.log(`${mark(opencode.ok)} OpenCode ${opencode.version ?? opencode.error ?? t('不可用', 'unavailable')}`); - console.log(`${mark(gateway.gatewayOpenCode?.ok === true)} ${t('Gateway 环境中的 OpenCode', 'OpenCode in Gateway environment')} ${gateway.gatewayOpenCode?.version ?? gateway.gatewayOpenCode?.error ?? t('不可用', 'unavailable')}${gateway.gatewayOpenCode?.executable ? `,${gateway.gatewayOpenCode.executable}` : ''}`); - console.log(`${mark(plugin.ok)} ${t('OpenCode 插件', 'OpenCode plugin')} ${plugin.spec || plugin.error || t('未配置', 'not configured')}${plugin.cachedVersion ? t(`(缓存 v${plugin.cachedVersion})`, ` (cached v${plugin.cachedVersion})`) : ''}`); - console.log(`${mark(database.ok)} ${t('数据库', 'Database')} ${database.path}${t(`(任务 ${database.counts.tasks},运行中 ${database.runningTasks})`, ` (tasks ${database.counts.tasks}, running ${database.runningTasks})`)}`); + console.log(`SuperTask doctor: ${ok ? t('正常', 'healthy', 'saludable') : t('异常', 'unhealthy', 'no saludable')}`); + console.log(`${mark(opencode.ok)} OpenCode ${opencode.version ?? opencode.error ?? t('不可用', 'unavailable', 'no disponible')}`); + console.log(`${mark(gateway.gatewayOpenCode?.ok === true)} ${t('Gateway 环境中的 OpenCode', 'OpenCode in Gateway environment', 'OpenCode en el entorno del Gateway')} ${gateway.gatewayOpenCode?.version ?? gateway.gatewayOpenCode?.error ?? t('不可用', 'unavailable', 'no disponible')}${gateway.gatewayOpenCode?.executable ? `,${gateway.gatewayOpenCode.executable}` : ''}`); + console.log(`${mark(plugin.ok)} ${t('OpenCode 插件', 'OpenCode plugin', 'plugin de OpenCode')} ${plugin.spec || plugin.error || t('未配置', 'not configured', 'no configurado')}${plugin.cachedVersion ? t(`(缓存 v${plugin.cachedVersion})`, ` (cached v${plugin.cachedVersion})`, ` (en caché v${plugin.cachedVersion})`) : ''}`); + console.log(`${mark(database.ok)} ${t('数据库', 'Database', 'Base de datos')} ${database.path}${t(`(任务 ${database.counts.tasks},运行中 ${database.runningTasks})`, ` (tasks ${database.counts.tasks}, running ${database.runningTasks})`, ` (tareas ${database.counts.tasks}, en ejecución ${database.runningTasks})`)}`); console.log(`${mark(gateway.status === 'online' && gateway.ready && gatewayEntryPinned && gatewayVersionMatchesPackage)} Gateway ${gateway.status ?? 'missing'}${gateway.pid ? `,PID ${gateway.pid}` : ''}${gateway.runningVersion ? `,v${gateway.runningVersion}` : ''}${gateway.gatewayEntry ? `,${gateway.gatewayEntry}` : ''}`); - console.log(`${mark(dashboard.ok)} Dashboard ${dashboard.enabled ? dashboard.url : t('已禁用', 'disabled')}`); + console.log(`${mark(dashboard.ok)} Dashboard ${dashboard.enabled ? dashboard.url : t('已禁用', 'disabled', 'desactivado')}`); if (options.smoke) { const smokeSummary = smoke && 'taskId' in smoke ? `task #${smoke.taskId}${smoke.runId ? ` / run #${smoke.runId}` : ''},${smoke.durationMs}ms` - : smoke?.error ?? t('未执行', 'not run'); - console.log(`${mark(smoke?.ok === true)} ${t('真实 Gateway 冒烟任务', 'Real Gateway smoke task')} ${smokeSummary}`); + : smoke?.error ?? t('未执行', 'not run', 'no ejecutado'); + console.log(`${mark(smoke?.ok === true)} ${t('真实 Gateway 冒烟任务', 'Real Gateway smoke task', 'Tarea smoke real del Gateway')} ${smokeSummary}`); } for (const warning of warnings) console.log(`! ${warning}`); } @@ -787,7 +787,7 @@ program program .command('install') - .description(t('用 PM2 安装 Gateway(开机启动、崩溃恢复、日志轮转)', 'install the Gateway with PM2 (startup, crash recovery, log rotation)')) + .description(t('用 PM2 安装 Gateway(开机启动、崩溃恢复、日志轮转)', 'install the Gateway with PM2 (startup, crash recovery, log rotation)', 'instalar el Gateway con PM2 (arranque, recuperación ante fallos y rotación de logs)')) .action(async () => { try { const { install: pm2Install } = await import('../daemon/pm2'); @@ -800,7 +800,7 @@ program program .command('uninstall') - .description(t('停止并移除 PM2 Gateway', 'stop and remove the PM2 Gateway')) + .description(t('停止并移除 PM2 Gateway', 'stop and remove the PM2 Gateway', 'detener y eliminar el Gateway de PM2')) .action(async () => { try { const { uninstall: pm2Uninstall } = await import('../daemon/pm2'); @@ -813,10 +813,10 @@ program program .command('upgrade') - .description(t('更新 OpenCode 插件、CLI 和 Gateway;已是最新版本时不重启', 'update the OpenCode plugin, CLI, and Gateway without restarting when already current')) - .option('--force', t('即使已是最新版本也重新安装并重启 Gateway', 'reinstall and restart the Gateway even when already current')) + .description(t('更新 OpenCode 插件、CLI 和 Gateway;已是最新版本时不重启', 'update the OpenCode plugin, CLI, and Gateway without restarting when already current', 'actualizar el plugin de OpenCode, la CLI y el Gateway sin reiniciar si ya están al día')) + .option('--force', t('即使已是最新版本也重新安装并重启 Gateway', 'reinstall and restart the Gateway even when already current', 'reinstalar y reiniciar el Gateway aunque ya esté al día')) .action(async (options: { force?: boolean }) => { - console.log(t('正在检查 opencode-supertask 更新...', 'Checking for opencode-supertask updates...')); + console.log(t('正在检查 opencode-supertask 更新...', 'Checking for opencode-supertask updates...', 'Comprobando actualizaciones de opencode-supertask...')); let installed: { gatewayEntry: string; version: string }; let previousVersion: string; let targetVersion: string; @@ -842,11 +842,11 @@ program } previousVersion = plugin.version ?? updater.resolveInstalledPlugin().version; } catch (error) { - console.error(t('无法检查当前版本,已取消升级:', 'Could not check the current version; upgrade cancelled: ') + console.error(t('无法检查当前版本,已取消升级:', 'Could not check the current version; upgrade cancelled: ', 'No se pudo comprobar la versión actual; actualización cancelada: ') + (error instanceof Error ? error.message : String(error))); process.exit(1); } - console.log(t('正在更新 opencode-supertask...', 'Updating opencode-supertask...')); + console.log(t('正在更新 opencode-supertask...', 'Updating opencode-supertask...', 'Actualizando opencode-supertask...')); try { installed = updater.installPluginVersion(targetVersion); } catch (err) { @@ -871,7 +871,7 @@ program `\nSuperTask 已升级:${result.before ?? 'unknown'} → ${result.after}`, `\nSuperTask upgraded: ${result.before ?? 'unknown'} → ${result.after}`, )); - console.log(t('Gateway 已重启。请重启 OpenCode 加载新插件。', 'Gateway restarted. Restart OpenCode to load the new plugin.')); + console.log(t('Gateway 已重启。请重启 OpenCode 加载新插件。', 'Gateway restarted. Restart OpenCode to load the new plugin.', 'Gateway reiniciado. Reinicia OpenCode para cargar el plugin nuevo.')); try { const { updateGlobalCli } = await import('../daemon/update'); const cli = updateGlobalCli(installed.version); @@ -881,10 +881,10 @@ program `Global CLI updated to v${installed.version} with ${cli.packageManager}.`, )); } else if (cli.action === 'not-installed') { - console.log(t('未找到全局 CLI;插件和 Gateway 升级已完成。', 'No global CLI installation was found; plugin and Gateway upgrade is complete.')); + console.log(t('未找到全局 CLI;插件和 Gateway 升级已完成。', 'No global CLI installation was found; plugin and Gateway upgrade is complete.', 'No se encontró una instalación global de la CLI; la actualización del plugin y del Gateway ha terminado.')); } } catch (cliError) { - console.error(t('插件和 Gateway 已升级,但全局 CLI 未更新:', 'Plugin and Gateway were upgraded, but the global CLI was not: ') + console.error(t('插件和 Gateway 已升级,但全局 CLI 未更新:', 'Plugin and Gateway were upgraded, but the global CLI was not: ', 'Se actualizaron el plugin y el Gateway, pero no la CLI global: ') + (cliError instanceof Error ? cliError.message : String(cliError))); console.error(t( `执行 npm install -g opencode-supertask@${installed.version} 或 bun add -g opencode-supertask@${installed.version},再运行 supertask doctor。`, @@ -901,7 +901,7 @@ program } catch (rollbackError) { detail += `; Gateway 已回滚,但 OpenCode 插件回滚失败: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`; } - console.error(t('Gateway 重启失败:', 'Gateway restart failed:'), detail); + console.error(t('Gateway 重启失败:', 'Gateway restart failed:', 'Falló el reinicio del Gateway:'), detail); process.exit(1); } }); diff --git a/src/web/index.tsx b/src/web/index.tsx index bfa4c33..0dd2432 100644 --- a/src/web/index.tsx +++ b/src/web/index.tsx @@ -40,6 +40,7 @@ import { formatFuture, formatRelative, icon, + intlLocale, renderLayout, runStatusText, statusText, @@ -283,23 +284,39 @@ function safeStatus(value: string | null): TaskStatus | 'unknown' { return value && TASK_STATUSES.has(value as TaskStatus) ? value as TaskStatus : 'unknown'; } +const SUPPORTED_LOCALES = new Set(['en', 'es', 'zh-CN']); + +function parseLocale(value: string | null | undefined): Locale | null { + if (!value) return null; + const normalized = value.trim().toLowerCase().replace('_', '-'); + if (normalized === 'zh' || normalized.startsWith('zh-')) return 'zh-CN'; + if (normalized === 'es' || normalized.startsWith('es-')) return 'es'; + if (normalized === 'en' || normalized.startsWith('en-')) return 'en'; + return SUPPORTED_LOCALES.has(value as Locale) ? value as Locale : null; +} + function resolveLocale(c: Context): Locale { - const requested = c.req.query('lang'); - if (requested === 'en' || requested === 'zh-CN') return requested; + const requested = parseLocale(c.req.query('lang')); + if (requested) return requested; const cookie = c.req.header('Cookie') ?? ''; const match = /(?:^|;\s*)supertask_locale=([^;]+)/.exec(cookie); if (match) { try { - const saved = decodeURIComponent(match[1]); - if (saved === 'en' || saved === 'zh-CN') return saved; + const saved = parseLocale(decodeURIComponent(match[1])); + if (saved) return saved; } catch { // Ignore malformed client cookies and continue with browser negotiation. } } const accepted = c.req.header('Accept-Language')?.toLowerCase() ?? ''; - return accepted.startsWith('en') ? 'en' : 'zh-CN'; + for (const part of accepted.split(',')) { + const tag = part.split(';')[0]?.trim() ?? ''; + const matched = parseLocale(tag); + if (matched) return matched; + } + return 'en'; } app.use('*', async (c, next) => { @@ -558,7 +575,7 @@ function durationControl( } function formatInterval(milliseconds: number, locale: Locale): string { - const formatter = new Intl.NumberFormat(locale === 'en' ? 'en-US' : 'zh-CN', { + const formatter = new Intl.NumberFormat(intlLocale(locale), { maximumFractionDigits: 1, }); if (milliseconds < 60_000) { diff --git a/src/web/ui.ts b/src/web/ui.ts index 2b21b8c..9b5e01c 100644 --- a/src/web/ui.ts +++ b/src/web/ui.ts @@ -1,4 +1,4 @@ -export type Locale = 'zh-CN' | 'en'; +export type Locale = 'zh-CN' | 'en' | 'es'; export type ActiveTab = 'tasks' | 'templates' | 'runs' | 'system'; const ZH = { @@ -615,15 +615,339 @@ const EN: Record = { 'a11y.refreshing': 'Refreshing', }; +const ES: Record = { + 'app.name': 'SuperTask', + 'app.dashboard': 'Panel', + 'app.tagline': 'Orquestación local fiable de agentes', + 'app.local': 'Ejecutándose en local', + 'app.footer': 'Local-first · Tus datos permanecen en este dispositivo', + 'nav.tasks': 'Tareas', + 'nav.templates': 'Tareas programadas', + 'nav.runs': 'Ejecuciones', + 'nav.system': 'Sistema', + 'page.tasks.title': 'Cola de tareas', + 'page.tasks.description': 'Sigue la prioridad, el estado de ejecución y los reintentos, y actúa sobre las tareas que requieren atención.', + 'page.templates.title': 'Tareas programadas', + 'page.templates.description': 'Crea y edita tareas cron, de intervalo fijo y de una sola vez, incluyendo modelo, agente y prompt.', + 'page.runs.title': 'Historial de ejecución', + 'page.runs.description': 'Inspecciona el estado, la duración, el heartbeat y la salida de cada ejecución del agente.', + 'page.system.title': 'Ajustes del sistema', + 'page.system.description': 'Ajusta el comportamiento del Gateway, inspecciona el trabajo activo y gestiona los datos locales.', + 'action.refresh': 'Actualizar', + 'action.details': 'Detalles', + 'action.retry': 'Reintentar', + 'action.cancel': 'Cancelar', + 'action.delete': 'Eliminar', + 'action.edit': 'Editar', + 'action.enable': 'Activar', + 'action.disable': 'Desactivar', + 'action.trigger': 'Ejecutar ahora', + 'action.continueSession': 'Continuar sesión', + 'action.logs': 'Ver registro', + 'action.hideLogs': 'Ocultar registro', + 'action.save': 'Guardar ajustes', + 'action.saveAndRestart': 'Guardar y reiniciar', + 'action.copy': 'Copiar datos en bruto', + 'action.close': 'Cerrar', + 'action.confirm': 'Confirmar', + 'action.clearDatabase': 'Vaciar base de datos', + 'action.createTask': 'Nueva tarea', + 'action.saveTask': 'Añadir a la cola', + 'action.updateTask': 'Guardar cambios', + 'action.createTemplate': 'Nueva tarea programada', + 'action.saveTemplate': 'Guardar tarea programada', + 'action.chooseFolder': 'Elegir carpeta', + 'action.chooseThisFolder': 'Elegir esta carpeta', + 'action.home': 'Inicio', + 'action.up': 'Subir', + 'action.copyCommand': 'Copiar comando', + 'action.showHidden': 'Mostrar carpetas ocultas', + 'action.hideHidden': 'Ocultar carpetas ocultas', + 'status.pending': 'Pendiente', + 'status.running': 'En ejecución', + 'status.done': 'Completada', + 'status.failed': 'Esperando reintento', + 'status.dead_letter': 'Detenida', + 'status.deadLetterHint': 'El sistema no volverá a ejecutar esta tarea automáticamente. Puede haber agotado los reintentos o una dependencia no puede continuar; revisa el error y reintenta manualmente si procede.', + 'status.deadLetterAction': 'Revisa el error y reintenta manualmente', + 'status.cancelled': 'Cancelada', + 'status.executionStillActive': 'El proceso de ejecución aún se está deteniendo y ocupa temporalmente un hueco', + 'status.unknown': 'Desconocido', + 'runStatus.running': 'En ejecución', + 'runStatus.done': 'Correcta', + 'runStatus.failed': 'Fallida', + 'stats.total': 'Total de tareas', + 'stats.pending': 'Pendientes', + 'stats.running': 'En ejecución', + 'stats.done': 'Completadas', + 'stats.failedDead': 'Esperando reintento y detenidas', + 'stats.templates': 'Tareas programadas', + 'stats.enabled': 'Activadas', + 'stats.disabled': 'Desactivadas', + 'stats.records': 'Total de ejecuciones', + 'stats.pageDone': 'Correctas en esta página', + 'stats.pageFailed': 'Fallidas en esta página', + 'stats.pageRunning': 'En ejecución aquí', + 'filter.all': 'Todas', + 'filter.searchTasks': 'Buscar tareas, proyectos, lotes, agentes o prompts en esta página', + 'filter.noResults': 'Ninguna tarea coincide con esta búsqueda', + 'table.id': 'ID', + 'table.task': 'Tarea', + 'table.name': 'Nombre', + 'table.agent': 'Agente', + 'table.status': 'Estado', + 'table.duration': 'Duración', + 'table.retries': 'Reintentos', + 'table.actions': 'Acciones', + 'table.type': 'Tipo', + 'table.rule': 'Regla', + 'table.lastRun': 'Última ejecución', + 'table.nextRun': 'Próxima ejecución', + 'table.run': 'Ejecución', + 'table.heartbeat': 'Heartbeat', + 'table.session': 'Sesión', + 'table.model': 'Modelo', + 'table.startedAt': 'Inicio', + 'table.pid': 'PID', + 'pagination.previous': 'Anterior', + 'pagination.next': 'Siguiente', + 'pagination.summary': 'Página {page} de {pages} · {total} elementos', + 'empty.tasks': 'Tu cola está vacía', + 'empty.tasksHint': 'Elige «Nueva tarea», o usa el plugin de OpenCode o supertask add.', + 'empty.templates': 'Aún no hay tareas programadas', + 'empty.templatesHint': 'Elige «Nueva tarea programada» para crear una.', + 'empty.runs': 'Aún no hay historial de ejecución', + 'empty.running': 'No hay tareas en ejecución ahora mismo', + 'schedule.cron': 'Cron', + 'schedule.recurring': 'Intervalo fijo', + 'schedule.delayed': 'Una sola vez', + 'schedule.unknown': 'Desconocido', + 'schedule.enabled': 'Activada', + 'schedule.disabled': 'Desactivada', + 'schedule.minutes': '{count} min', + 'schedule.seconds': '{count} s', + 'schedule.hours': '{count} h', + 'schedule.days': '{count} días', + 'schedule.overdue': 'Vencida', + 'duration.unit': 'Unidad de tiempo', + 'duration.seconds': 'segundos', + 'duration.minutes': 'minutos', + 'duration.hours': 'horas', + 'duration.days': 'días', + 'duration.systemDefault': 'Usar el timeout predeterminado del Gateway', + 'duration.immediate': 'Reintentar de inmediato', + 'duration.custom': 'Personalizado…', + 'duration.every': 'Cada {duration}', + 'system.worker': 'Ejecución de tareas', + 'system.scheduler': 'Servicio de tareas programadas', + 'system.watchdog': 'Monitor en tiempo de ejecución', + 'system.maxConcurrency': 'Concurrencia máxima', + 'system.pollInterval': 'Intervalo de sondeo', + 'system.heartbeatInterval': 'Intervalo de heartbeat', + 'system.taskTimeout': 'Timeout de tarea', + 'system.schedulerEnabled': 'Activar tareas programadas', + 'system.checkInterval': 'Intervalo de comprobación', + 'system.heartbeatTimeout': 'Timeout de heartbeat', + 'system.cleanupInterval': 'Intervalo de limpieza', + 'system.retentionDays': 'Retención de datos', + 'system.milliseconds': 'ms', + 'system.seconds': 'segundos', + 'system.minutes': 'minutos', + 'system.hours': 'horas', + 'system.days': 'días', + 'system.activeTemplates': 'Tareas programadas activas', + 'system.saveHint': 'Reinicia el Gateway para aplicar los ajustes guardados.', + 'system.configApplied': 'Los ajustes mostrados aquí están activos.', + 'system.configPending': 'Los ajustes están guardados, pero este Gateway sigue usando los valores de su último arranque.', + 'system.configForeground': 'Esta página no está conectada a la configuración en ejecución del Gateway. Reinicia el Gateway tras guardar.', + 'system.configRestartManually': 'Los ajustes están guardados pero no activos. Vuelve al terminal que lanzó el Gateway, pulsa Ctrl-C y vuelve a ejecutar supertask gateway.', + 'system.runningTasks': 'Tareas activas ({running} / {limit} concurrentes)', + 'system.taskStats': 'Resumen de tareas', + 'system.configFile': 'Archivo de configuración', + 'system.path': 'Ruta', + 'system.fileExists': 'El archivo existe', + 'system.yes': 'Sí', + 'system.noDefault': 'No, usando valores predeterminados', + 'system.danger': 'Zona de peligro', + 'system.dangerDescription': 'Primero se crea una copia de seguridad verificada; después se vacían de forma transaccional las tareas, ejecuciones y plantillas. El trabajo activo bloquea esta operación.', + 'template.createTitle': 'Nueva tarea programada', + 'template.editTitle': 'Editar tarea programada', + 'template.formSubtitle': 'Elige cuándo ejecutar y qué proyecto, modelo y prompt debe usar OpenCode.', + 'template.name': 'Nombre', + 'template.cwd': 'Directorio del proyecto', + 'template.cwdHint': 'OpenCode ejecuta la tarea en este directorio.', + 'template.agent': 'Agente', + 'template.model': 'Modelo', + 'template.variant': 'Variante del modelo', + 'template.prompt': 'Prompt', + 'template.scheduleType': 'Programación', + 'template.cronExpr': 'Expresión cron', + 'template.cronHint': 'Ejemplo: 0 9 * * * (cada día a las 09:00)', + 'template.interval': 'Intervalo', + 'template.runAt': 'Ejecutar a las', + 'template.durationHint': 'Admite 30s, 5min, 1h, 2d', + 'template.intervalHint': 'Elige una frecuencia habitual; personaliza solo si es necesario.', + 'template.retryBackoffHint': 'Cuánto esperar tras un fallo antes de reintentar.', + 'template.timeoutHint': 'Déjalo en blanco para usar el timeout predeterminado de tarea del Gateway.', + 'template.advanced': 'Más ajustes de ejecución', + 'template.category': 'Categoría', + 'template.batchId': 'ID de lote', + 'template.importance': 'Importancia (1-5)', + 'template.urgency': 'Urgencia (1-5)', + 'template.maxInstances': 'Límite de programación automática', + 'template.maxInstancesHint': 'Solo limita la programación automática. «Ejecutar ahora» siempre encola una tarea. Las instancias activas incluyen pendientes, en ejecución y en espera de reintento.', + 'template.maxRetries': 'Reintentos ante fallo', + 'template.retryBackoff': 'Espera entre reintentos', + 'template.timeout': 'Timeout de ejecución', + 'template.optional': 'Opcional', + 'template.futureOnly': 'Los ajustes guardados se aplican a las tareas creadas en el futuro. Editar no cambia las tareas en cola o en ejecución.', + 'projects.title': 'Proyectos', + 'projects.description': 'El directorio del proyecto es la clave de aislamiento. Revisa el trabajo activo y en cola antes de añadir más.', + 'projects.all': 'Todos los proyectos', + 'projects.legacy': 'Sin agrupar', + 'projects.legacyHint': 'Tareas antiguas sin directorio de proyecto. Puedes verlas, cancelarlas o eliminarlas; recrea bajo el proyecto correcto para modificarlas.', + 'projects.counts': 'En ejecución {running} · en cola {pending} · con problemas {failed}', + 'task.createTitle': 'Nueva tarea', + 'task.editTitle': 'Editar tarea', + 'task.formSubtitle': 'La tarea entra de inmediato en la cola persistente y espera automáticamente si la concurrencia está llena.', + 'task.batchHint': 'Las tareas con el mismo ID de lote no vacío se ejecutan en serie. En blanco elimina la restricción de lote; la concurrencia global y las dependencias siguen aplicándose.', + 'task.projectExisting': 'Este proyecto tiene {total} tareas: {running} en ejecución, {pending} en cola y {failed} con problemas.', + 'task.projectNew': 'Este es un grupo de proyecto nuevo. Aparecerá en la lista de proyectos tras crearlo.', + 'catalog.chooseProject': 'Elige primero un directorio de proyecto', + 'catalog.defaultModel': 'Usar el modelo predeterminado del agente / OpenCode', + 'catalog.defaultProvider': 'Modelo predeterminado', + 'catalog.defaultVariant': 'Usar el predeterminado del agente / modelo', + 'catalog.provider': 'Proveedor del modelo', + 'catalog.model': 'Modelo', + 'catalog.modelHint': 'Elige un proveedor y luego un modelo devuelto por opencode models --verbose para este proyecto. El valor predeterminado no pasa -m.', + 'catalog.variantHint': 'Solo se muestran las variantes declaradas por el modelo seleccionado. El valor predeterminado no pasa --variant.', + 'catalog.agentHint': 'Cargado desde opencode agent list de este proyecto.', + 'catalog.loading': 'Cargando agentes y modelos disponibles para este proyecto…', + 'catalog.loaded': 'Se cargaron {agents} agentes y {models} modelos desde OpenCode local.', + 'catalog.failed': 'No se pudo cargar la configuración de OpenCode de este proyecto: {error}', + 'catalog.primary': 'agente principal', + 'catalog.subagent': 'subagente', + 'catalog.all': 'agente general', + 'directory.title': 'Elegir directorio del proyecto', + 'directory.subtitle': 'OpenCode se ejecuta en este directorio y se cargan sus agentes y modelos específicos del proyecto.', + 'directory.empty': 'Esta carpeta no tiene subcarpetas', + 'logs.command': 'Comando ejecutado', + 'logs.output': 'Salida del agente', + 'logs.error': 'Motivo del fallo', + 'logs.tools': 'Llamadas a herramientas', + 'logs.raw': 'Ver registro de ejecución en bruto', + 'logs.noText': 'Esta ejecución no produjo texto visible. Revisa el registro en bruto para más detalles.', + 'theme.label': 'Tema', + 'theme.system': 'Sistema', + 'theme.light': 'Claro', + 'theme.dark': 'Oscuro', + 'language.label': 'Idioma', + 'details.title': 'Detalles', + 'details.subtitle': 'La información clave está organizada abajo; los datos en bruto solo sirven para diagnóstico.', + 'details.taskTitle': 'Detalles de la tarea', + 'details.runTitle': 'Detalles de la ejecución', + 'details.templateTitle': 'Detalles de la tarea programada', + 'details.raw': 'Ver datos en bruto (JSON)', + 'details.copySuccess': 'Datos en bruto copiados', + 'details.id': 'ID', + 'details.project': 'Directorio del proyecto', + 'details.variant': 'Variante del modelo', + 'details.prompt': 'Prompt', + 'details.result': 'Resultado / motivo del fallo', + 'details.category': 'Categoría', + 'details.batch': 'Lote', + 'details.dependency': 'Dependencia', + 'details.importance': 'Importancia', + 'details.urgency': 'Urgencia', + 'details.retryCount': 'Reintentos usados / permitidos', + 'details.retryBackoff': 'Espera entre reintentos', + 'details.timeout': 'Timeout de ejecución', + 'details.createdAt': 'Creada', + 'details.updatedAt': 'Actualizada', + 'details.startedAt': 'Iniciada', + 'details.finishedAt': 'Finalizada', + 'details.scheduledAt': 'Programada para', + 'details.enabled': 'Ejecuciones automáticas', + 'details.scheduleRule': 'Regla de programación', + 'details.maxInstances': 'Límite de programación automática', + 'details.maxRetries': 'Reintentos ante fallo', + 'details.lastRun': 'Última ejecución', + 'details.nextRun': 'Próxima ejecución', + 'details.taskId': 'Tarea', + 'details.session': 'Sesión de OpenCode', + 'details.heartbeat': 'Último heartbeat', + 'details.process': 'Procesos', + 'details.history': 'Historial de ejecuciones', + 'details.noHistory': 'Aún no hay ejecuciones', + 'details.none': 'Ninguno', + 'details.default': 'Usar el predeterminado del sistema', + 'details.enabledYes': 'Activada', + 'details.enabledNo': 'Desactivada', + 'dialog.cancelTask': '¿Cancelar la tarea #{id}?', + 'dialog.cancelTaskBody': 'Una tarea en ejecución terminará su grupo de procesos gestionado en el siguiente sondeo del worker.', + 'dialog.retryTask': '¿Reintentar la tarea #{id}?', + 'dialog.retryTaskBody': 'La tarea vuelve a pendiente y se restablece su presupuesto de reintentos automáticos.', + 'dialog.deleteTask': '¿Eliminar la tarea #{id}?', + 'dialog.deleteTaskBody': 'La tarea y su historial de ejecución se eliminarán de forma permanente.', + 'dialog.disableTemplate': '¿Desactivar esta programación?', + 'dialog.disableTemplateBody': 'Dejará de crear tareas nuevas automáticamente. Las tareas existentes no cambian.', + 'dialog.deleteTemplate': '¿Eliminar esta programación?', + 'dialog.deleteTemplateBody': 'Esta configuración de tarea programada se eliminará de forma permanente.', + 'dialog.triggerTemplate': '¿Ejecutar esta programación ahora?', + 'dialog.triggerTemplateBody': 'Se encola de inmediato una tarea con los ajustes actuales. Si la concurrencia global está llena, esperará a ejecutarse.', + 'dialog.clearTitle': 'Confirmar vaciado de la base de datos', + 'dialog.clearBody': 'Esto elimina todas las tareas, ejecuciones y tareas programadas tras crear una copia de seguridad.', + 'dialog.clearInstruction': 'Escribe CLEAR para confirmar', + 'dialog.restartGateway': '¿Reiniciar y aplicar los ajustes?', + 'dialog.restartGatewayBody': 'El Gateway no estará disponible brevemente y debería recuperarse con PM2 en unos segundos.', + 'dialog.restartGatewayRunningBody': 'Hay {count} tareas en ejecución. El sistema espera primero; tras el periodo de gracia, las tareas incompletas se detienen de forma segura y vuelven a la cola antes de que PM2 restaure el Gateway.', + 'feedback.retryFailed': 'El reintento falló', + 'feedback.cancelFailed': 'La cancelación falló', + 'feedback.deleteFailed': 'La eliminación falló', + 'feedback.requestFailed': 'La solicitud falló', + 'feedback.triggered': 'Tarea #{id} creada', + 'feedback.taskCreated': 'Tarea #{id} añadida a la cola', + 'feedback.taskUpdated': 'Tarea #{id} actualizada', + 'feedback.templateCreated': 'Tarea programada creada', + 'feedback.templateUpdated': 'Tarea programada actualizada', + 'feedback.configSaved': 'Ajustes guardados', + 'feedback.sessionCommandCopied': 'Comando de sesión copiado. Pégalo en un terminal para continuar.', + 'feedback.commandCopied': 'Comando de ejecución copiado', + 'feedback.restarting': 'El Gateway se está reiniciando…', + 'feedback.restartTimeout': 'El Gateway aún no se ha recuperado. Actualiza más tarde o comprueba el estado de PM2.', + 'feedback.databaseCleared': 'Base de datos vaciada. Copia de seguridad: {path}', + 'feedback.copyFailed': 'La copia falló. Selecciona el contenido manualmente.', + 'a11y.skip': 'Saltar al contenido principal', + 'a11y.refreshing': 'Actualizando', +}; + +const MESSAGES: Record> = { + 'zh-CN': ZH, + en: EN, + es: ES, +}; + export function t( locale: Locale, key: MessageKey, values: Record = {}, ): string { - const template = (locale === 'en' ? EN : ZH)[key]; + const template = MESSAGES[locale][key]; return template.replace(/\{([a-zA-Z]+)\}/g, (_, name: string) => String(values[name] ?? `{${name}}`)); } +export function intlLocale(locale: Locale): string { + if (locale === 'en') return 'en-US'; + if (locale === 'es') return 'es-ES'; + return 'zh-CN'; +} + +export function nextLocale(locale: Locale): Locale { + if (locale === 'en') return 'es'; + if (locale === 'es') return 'zh-CN'; + return 'en'; +} + export function statusText(locale: Locale, status: string): string { const key = `status.${status}` as MessageKey; return key in ZH ? t(locale, key) : t(locale, 'status.unknown'); @@ -647,8 +971,7 @@ export function formatRelative(timestamp: number | null, locale: Locale): string if (!timestamp) return '—'; const deltaMs = timestamp - Date.now(); const abs = Math.abs(deltaMs); - const language = locale === 'en' ? 'en' : 'zh-CN'; - const formatter = new Intl.RelativeTimeFormat(language, { numeric: 'auto' }); + const formatter = new Intl.RelativeTimeFormat(intlLocale(locale), { numeric: 'auto' }); if (abs < 60_000) return formatter.format(Math.round(deltaMs / 1000), 'second'); if (abs < 3_600_000) return formatter.format(Math.round(deltaMs / 60_000), 'minute'); if (abs < 86_400_000) return formatter.format(Math.round(deltaMs / 3_600_000), 'hour'); @@ -664,7 +987,7 @@ export function formatFuture(timestamp: number | null, locale: Locale): string { export function formatDateTime(value: Date | number | null, locale: Locale): string { if (!value) return '—'; const date = value instanceof Date ? value : new Date(value); - return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', { + return new Intl.DateTimeFormat(intlLocale(locale), { month: 'short', day: '2-digit', hour: '2-digit', @@ -1128,9 +1451,8 @@ export function renderLayout(options: { { id: 'system' as const, href: '/system', icon: 'system' as const }, ]; const ui = JSON.stringify(clientMessages(locale)).replace(/ - + @@ -1157,8 +1479,9 @@ export function renderLayout(options: {
+
- +