diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d982206..8b2f0e190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.0.5-beta.5 (2026-05-27) +- Added scheduled tasks for recurring agent work +- Added high-priority language support and completed missing locale keys for broader localization coverage +- Fixed MiMo Pro TTS routing so it uses chat-compatible provider behavior +- Preserved paired Feishu users across remote-control state updates +- 新增定时任务能力,支持周期性的 Agent 工作 +- 新增高优先级语言支持,并补齐缺失的本地化 keys,扩大多语言覆盖 +- 修复 MiMo Pro 的 TTS 路由,使其使用兼容聊天的 Provider 行为 +- 修复飞书远程控制状态更新时配对用户被丢失的问题 + ## v1.0.5-beta.4 (2026-05-25) - Added session tape memory to persist and compress agent conversation history more reliably - Synced CUA driver to v0.2.0 with diagnostic tools and improved app launching diff --git a/docs/archives/chat-audio-tts-routing/plan.md b/docs/archives/chat-audio-tts-routing/plan.md new file mode 100644 index 000000000..e711ff914 --- /dev/null +++ b/docs/archives/chat-audio-tts-routing/plan.md @@ -0,0 +1,21 @@ +# Chat Audio TTS Routing Plan + +## Implementation + +- Tighten `isChatAudioTtsModel` so MiMo IDs must match the known MiMo prefixes and include a standalone `tts` segment. +- Update `executeTtsPatternB` to treat `message.content` as unknown response data. +- Extract audio parts only after checking `Array.isArray(message.content)`. +- Keep `message.audio.data` as the first-preference extraction path. +- Leave the existing missing-audio error path in place for responses that contain no audio data. + +## Test Strategy + +- Add shared helper coverage for MiMo TTS and non-TTS model IDs. +- Extend `test/main/presenter/llmProviderPresenter/aiSdkRuntime.test.ts`. +- Cover `mimo-v2.5-pro` using normal chat streaming instead of direct TTS `fetch`. +- Cover a successful HTTP response with string `message.content` and no audio payload. +- Assert the runtime rejects with the expected missing-audio error, not `content.find is not a function`. + +## Compatibility + +This change is backward-compatible for actual MiMo TTS models. Non-TTS MiMo chat models stop being routed through TTS handling, while providers returning `message.audio.data` or array content audio parts keep the same behavior. diff --git a/docs/archives/chat-audio-tts-routing/spec.md b/docs/archives/chat-audio-tts-routing/spec.md new file mode 100644 index 000000000..bfbbca2c5 --- /dev/null +++ b/docs/archives/chat-audio-tts-routing/spec.md @@ -0,0 +1,26 @@ +# Chat Audio TTS Routing + +## User Story + +When a MiMo chat model is selected, DeepChat should only enter chat-audio TTS handling for model IDs that are actually TTS variants. Regular MiMo chat models such as `MiMo-V2.5-Pro` should use the normal chat streaming runtime. + +## Acceptance Criteria + +- `mimo-v2.5-pro` and provider-prefixed variants are not classified as TTS models. +- MiMo model IDs with a `tts` segment, such as `mimo-v2.5-tts`, continue to use chat-audio TTS Pattern B. +- Chat-audio TTS responses with `choices[0].message.audio.data` continue to emit cached audio. +- Chat-audio TTS responses with array `choices[0].message.content` can still extract an audio content part. +- Chat-audio TTS responses with string `choices[0].message.content` do not throw a `TypeError`. +- If no audio payload exists, DeepChat raises the existing missing-audio error instead of a response-shape crash. + +## Non-Goals + +- No changes to renderer audio display behavior. +- No changes to request body construction for chat-audio TTS models. + +## Constraints + +- Keep the fix localized to the AI SDK runtime. +- Keep TTS model classification in shared helpers so provider and agent runtime checks agree. +- Preserve current OpenAI-compatible chat-audio behavior. +- Add focused regression coverage for the reported MiMo Pro misrouting and response shape. diff --git a/docs/archives/chat-audio-tts-routing/tasks.md b/docs/archives/chat-audio-tts-routing/tasks.md new file mode 100644 index 000000000..9ff9742f3 --- /dev/null +++ b/docs/archives/chat-audio-tts-routing/tasks.md @@ -0,0 +1,8 @@ +# Chat Audio TTS Routing Tasks + +- [x] Create SDD issue artifacts. +- [x] Guard chat-audio TTS content audio extraction by response shape. +- [x] Add a regression test for string `message.content`. +- [x] Tighten MiMo chat-audio TTS classification. +- [x] Add regression coverage for MiMo Pro chat routing. +- [x] Run focused test coverage and quality checks. diff --git a/docs/archives/i18n-missing-translations/plan.md b/docs/archives/i18n-missing-translations/plan.md new file mode 100644 index 000000000..94b1e096d --- /dev/null +++ b/docs/archives/i18n-missing-translations/plan.md @@ -0,0 +1,25 @@ +# Plan + +## Scope + +The scan found four real missing key paths that are referenced from renderer code: + +- `mcp.errors.loadClientsFailed` +- `mcp.prompts.required` +- `promptSetting.uploadFailed` +- `settings.mcp.noServersDescription` + +`searchDisclaimer` is supplied from each locale `index.ts`, and `settings.display.*` is dynamically built from existing `text-sm`, `text-base`, `text-lg`, `text-xl`, and `text-2xl` keys, so those are not changed. + +## Implementation + +- Add the missing keys to every locale JSON file in the matching namespace. +- Re-run the i18n type generator so `src/types/i18n.d.ts` reflects the source locale. +- Validate with format, i18n check, and lint. + +## Test Strategy + +- Run `pnpm run i18n:types`. +- Run `pnpm run format`. +- Run `pnpm run i18n`. +- Run `pnpm run lint`. diff --git a/docs/archives/i18n-missing-translations/spec.md b/docs/archives/i18n-missing-translations/spec.md new file mode 100644 index 000000000..e33478126 --- /dev/null +++ b/docs/archives/i18n-missing-translations/spec.md @@ -0,0 +1,22 @@ +# Missing i18n Translations + +## User Story + +As a DeepChat user, I want every supported locale to provide translations for UI keys that are currently used by the app so that the interface never falls back to raw i18n key strings. + +## Acceptance Criteria + +- All statically used i18n keys found missing from the active locale bundles are added to every supported locale. +- `pnpm run i18n` reports no missing or invalid translations. +- The generated i18n type definitions include the restored source-locale keys. + +## Non-Goals + +- Do not rewrite existing translations unrelated to missing keys. +- Do not remove stale extra keys that are not currently used by the UI. +- Do not change runtime i18n loading behavior. + +## Constraints + +- Keep the existing locale file layout under `src/renderer/src/i18n//`. +- Preserve interpolation placeholders such as `{count}` and `{serverName}` exactly where needed. diff --git a/docs/archives/i18n-missing-translations/tasks.md b/docs/archives/i18n-missing-translations/tasks.md new file mode 100644 index 000000000..1438e7925 --- /dev/null +++ b/docs/archives/i18n-missing-translations/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Create SDD notes for the missing translation fix. +- [x] Add `mcp.errors.loadClientsFailed` and `mcp.prompts.required` in all locales. +- [x] Add `promptSetting.uploadFailed` in all locales. +- [x] Add `settings.mcp.noServersDescription` in all locales. +- [x] Regenerate i18n types and run validation commands. +- [x] Archive the completed SDD notes. diff --git a/docs/features/high-priority-i18n-languages/plan.md b/docs/features/high-priority-i18n-languages/plan.md new file mode 100644 index 000000000..686642c2e --- /dev/null +++ b/docs/features/high-priority-i18n-languages/plan.md @@ -0,0 +1,27 @@ +# High Priority i18n Languages Plan + +## Scope + +Implement the requested locales by following the existing static i18n bundle pattern. The source of truth for required keys is `src/renderer/src/i18n/zh-CN`, with `en-US` as a secondary reference for shorter Latin-script phrasing. + +## Implementation + +- Create one locale directory per target locale under `src/renderer/src/i18n/`. +- Reuse the existing locale `index.ts` import/export shape for each new locale. +- Register new locale modules in `src/renderer/src/i18n/index.ts`. +- Add language options in `src/renderer/settings/components/DisplaySettings.vue`. +- Add new locale codes to `ConfigPresenter.getSystemLanguage()`, `ChatLanguage`, and the DeepChat settings Agent tool schema. +- Extend shared context-menu and error-message translations in `src/shared/i18n.ts`. +- Keep RTL handling unchanged because all requested locales are LTR. + +## Validation + +- Run a structural comparison against `zh-CN` for all target locale JSON files. +- Run `pnpm run format`. +- Run `pnpm run i18n`. +- Run `pnpm run lint`. + +## Risks + +- The largest risk is incomplete or malformed JSON translation files. Mitigation: validate parseability and exact key parity. +- Some UI strings may become long in German, Polish, Turkish, or Vietnamese. Mitigation: prefer natural but concise desktop UI wording and use English as a length reference where appropriate. diff --git a/docs/features/high-priority-i18n-languages/spec.md b/docs/features/high-priority-i18n-languages/spec.md new file mode 100644 index 000000000..506b1a719 --- /dev/null +++ b/docs/features/high-priority-i18n-languages/spec.md @@ -0,0 +1,29 @@ +# High Priority i18n Languages + +## User Stories + +- As a DeepChat desktop user in Spain, Germany, Turkey, Indonesia, Malaysia, Italy, Poland, or Vietnam, I can select my language in Display settings and use the app with readable local UI text. +- As a user whose system locale is one of the supported locales, DeepChat can resolve the matching app language when language is set to System. +- As an Agent Skills user, product and technical names such as DeepChat, Agent, Skills, MCP, Dify, and model/provider names remain recognizable and are not mistranslated. + +## Acceptance Criteria + +- Add full renderer i18n bundles for `es-ES`, `de-DE`, `tr-TR`, `id-ID`, `ms-MY`, `it-IT`, `pl-PL`, and `vi-VN`. +- Each new locale has the same JSON files and key structure as `zh-CN`. +- New locales are registered in renderer, settings renderer, floating renderer, language selector options, system-locale matching, shared chat settings types, and DeepChat settings Agent tool language schema. +- Shared native menu and error-label translations support the new locales where the shared i18n helper is used. +- Translation wording follows the Chinese source meaning, with English used as length/reference for Latin-script languages. +- Product and domain terms stay untranslated where requested: DeepChat, Agent, Skills, MCP, Dify, model/provider brand names, API, JSON, URL, token, prompt, and similar technical identifiers. +- `pnpm run format`, `pnpm run i18n`, and `pnpm run lint` pass. + +## Non-Goals + +- No UI layout redesign. +- No new runtime language-loading architecture. +- No locale-specific date, number, or plural-rule behavior beyond existing vue-i18n support unless needed by validation. + +## Constraints + +- Preserve existing keys, placeholders, interpolation variables, markdown, and JSON syntax. +- Keep translations clear for desktop application users; avoid jargon-heavy or literal machine-style phrasing. +- Resolve implementation without `[NEEDS CLARIFICATION]` markers. diff --git a/docs/features/high-priority-i18n-languages/tasks.md b/docs/features/high-priority-i18n-languages/tasks.md new file mode 100644 index 000000000..2a14ad85e --- /dev/null +++ b/docs/features/high-priority-i18n-languages/tasks.md @@ -0,0 +1,8 @@ +# High Priority i18n Languages Tasks + +- [x] Create SDD artifacts for the i18n expansion. +- [x] Add translated locale bundles for `es-ES`, `de-DE`, `tr-TR`, `id-ID`, `ms-MY`, `it-IT`, `pl-PL`, and `vi-VN`. +- [x] Register all new locales in renderer and main-process language support. +- [x] Extend shared menu/error translations for the new locale codes. +- [x] Validate locale key parity and JSON parseability. +- [x] Run `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. diff --git a/docs/features/scheduled-tasks/plan.md b/docs/features/scheduled-tasks/plan.md new file mode 100644 index 000000000..801199c4c --- /dev/null +++ b/docs/features/scheduled-tasks/plan.md @@ -0,0 +1,63 @@ +# Implementation Plan + +## Architecture + +- **Shared types** (`src/shared/scheduledTasks.ts`) define the `Trigger`, + `Action`, `ScheduledTask`, and `ScheduledTasksSettings` shapes. +- **Route contracts** (`src/shared/contracts/routes/scheduledTasks.routes.ts`) + expose `scheduledTasks.{list,upsert,delete,toggle,fireNow}` via Zod + schemas, mirroring `onboarding.routes.ts`. +- **Persistence** is handled in `ConfigPresenter` (`get/setScheduledTasks`) + with a `normalizeScheduledTasksConfig` pass identical in pattern to + `normalizeHooksNotificationsConfig`. +- **Scheduling** lives in a new `ScheduledTasksService` + (`src/main/presenter/scheduledTasks/index.ts`). One `setTimeout` per + armed task, chained at most 12h at a time. Public surface: + - `start()` — read tasks, run startup pass (one-shot backfill, arm next + slot for recurring), called from the existing lifecycle init flow. + - `stop()` — clear all armed timers (called on app shutdown). + - `list()` / `upsert(task)` / `delete(id)` / `toggle(id, enabled)` / + `fireNow(id)` — back the IPC routes and rearm timers on mutation. + - `computeNextFireAt(task, after)` — pure function, exported for tests. +- **Action dispatch** is a small helper inside the service: switch on + `task.action.kind`, then call `notificationPresenter` and/or + `eventBus.sendToRenderer(DEEPLINK_EVENTS.START, ...)` and/or + `sessionService.createSession(...)`. + +## Wiring + +- `Presenter` constructor (`src/main/presenter/index.ts`) instantiates + `ScheduledTasksService` next to `hooksNotifications`, passing it + `configPresenter`, `notificationPresenter`, `windowPresenter`, and a + thunk that resolves `sessionService` lazily (the route runtime owns + sessionService, so the service exposes a setter the route runtime calls + during bootstrap). +- `src/main/routes/index.ts` wires the five new route cases against + `runtime.scheduledTasksService` and, in the same place that constructs + the runtime, sets the service's session-service reference so auto-send + has somewhere to call. +- Lifecycle `after-start` hook invokes `scheduledTasksService.start()` + after the other presenters have come up; the existing `beforeQuit` hook + calls `stop()`. + +## UI + +- Settings navigation adds `settings-scheduled-tasks` (group `tools`, + position 5.6, icon `lucide:clock-9`). +- `ScheduledTasksSettings.vue` mirrors `NotificationsHooksSettings.vue`: + ScrollArea + header + "新建任务" button + bordered cards per task. +- A renderer client `ScheduledTasksClient.ts` matches the + `OnboardingClient` shape. +- i18n keys go in every locale under `routes.settings-scheduled-tasks` and + `settings.scheduledTasks.*` so `pnpm run i18n` stays green. + +## Validation + +- `pnpm run format` +- `pnpm run i18n` +- `pnpm run lint` +- `pnpm run typecheck` +- Unit tests in `test/main/presenter/scheduledTasks.test.ts` cover + `computeNextFireAt` (daily wrap, weekly across the week, one-shot past + with/without `lastFiredAt`) and `normalizeScheduledTasksConfig` + (drops malformed entries, deduplicates ids, preserves valid ones). diff --git a/docs/features/scheduled-tasks/spec.md b/docs/features/scheduled-tasks/spec.md new file mode 100644 index 000000000..725c20bb2 --- /dev/null +++ b/docs/features/scheduled-tasks/spec.md @@ -0,0 +1,59 @@ +# Scheduled Tasks + +## Problem + +Closes [#1567](https://github.com/ThinkInAIXYZ/deepchat/issues/1567). + +Users want to schedule "reminders" and "planned tasks" inside DeepChat — +either a plain notification ("drink water at 4pm every day") or a scheduled +chat prompt ("every morning at 9 ask the deepchat agent for today's plan"). +No equivalent feature exists today; the only time-aware paths are +`hooksNotifications` (event-driven, not time-driven) and the deeplink +"start" flow (which has `autoSend` security-disabled, so it can only +prefill a chat draft). + +## User Story + +As a DeepChat user, I want to create a scheduled task with a trigger time +(once at a specific datetime, daily, or weekly on a chosen day) and an +action (raise a system notification, prefill a new chat thread with a +preset prompt, or auto-send a preset prompt to a chosen agent/model). I +want my tasks to persist across app restarts; one-shot tasks that I missed +because the app was closed should still fire on next launch. + +## Acceptance Criteria + +- A "定时任务" entry exists in Settings → Tools (between Notifications & + Hooks and Plugins). It lists, creates, edits, toggles, deletes, and + manually fires user-defined scheduled tasks. +- Each task has: + - A name and an enabled toggle. + - A trigger of one of three kinds: `once` (a specific datetime), + `daily` (hour + minute), or `weekly` (day-of-week + hour + minute). + - An action of one of two kinds: `notify` (title + body for the system + notification) or `prompt` (notification title + chat message + optional + agent / provider / model / system prompt + `autoSend` toggle). +- When a task fires: + - `notify`: a system notification appears via `notificationPresenter`, + subject to the existing `notificationsEnabled` config. + - `prompt` with `autoSend = false`: a system notification appears and the + main window's new-thread page receives the deeplink-start payload, + prefilling the chat input. + - `prompt` with `autoSend = true`: `sessionService.createSession` is + invoked directly using the configured agent/provider/model, so the LLM + actually responds without user interaction. A notification is raised + when the session is created. +- One-shot tasks whose `firesAt` was in the past at launch and that have no + `lastFiredAt` recorded are fired once on startup (backfill). Recurring + tasks are not backfilled — they simply jump to the next slot. +- Task records survive app restart (persisted through `ConfigPresenter`'s + ElectronStore as the `scheduledTasks` key). + +## Non-goals + +- Cron-expression input. Daily / weekly / once is sufficient for the + feature ask; a future iteration may add `cron` and `interval` trigger + kinds. +- Per-task timezone handling. Triggers use the OS's local time. +- Letting the LLM schedule tasks via an MCP tool. Possible follow-up. +- Calendar / iCal export. diff --git a/docs/features/scheduled-tasks/tasks.md b/docs/features/scheduled-tasks/tasks.md new file mode 100644 index 000000000..e03a202ef --- /dev/null +++ b/docs/features/scheduled-tasks/tasks.md @@ -0,0 +1,16 @@ +# Tasks + +- [x] Add SDD artifacts. +- [x] Define shared types (`src/shared/scheduledTasks.ts`) and route contracts. +- [x] Implement `ScheduledTasksService` (presenter + `computeNextFireAt` + action dispatch). +- [x] Wire `ConfigPresenter` persistence (`scheduledTasks` key) with normalize-on-read. +- [x] Register routes in `src/main/routes/index.ts` and instantiate in `Presenter` constructor. +- [x] Hook lifecycle: start on `after-start`, stop on `beforeQuit`. +- [x] Add renderer client `src/renderer/api/ScheduledTasksClient.ts`. +- [x] Add settings navigation entry and dynamic route component. +- [x] Implement `ScheduledTasksSettings.vue` (CRUD, mirror NotificationsHooks layout). +- [x] Add i18n keys across all locales. +- [x] Unit tests for `computeNextFireAt` and `normalizeScheduledTasksConfig`. +- [x] Add service tests for notification firing, one-shot disable, and prompt auto-send dispatch. +- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`. +- [x] Address PR review comments without changing scheduled-task behavior. diff --git a/docs/issues/feishu-pairing-save-race/plan.md b/docs/issues/feishu-pairing-save-race/plan.md new file mode 100644 index 000000000..6fb7593a6 --- /dev/null +++ b/docs/issues/feishu-pairing-save-race/plan.md @@ -0,0 +1,23 @@ +# Plan + +## Implementation Approach +- Inspect `RemoteControlPresenter.saveFeishuSettings` and binding store ownership of Feishu paired users. +- Update Feishu settings save to persist editable settings while preserving `config.pairedUserOpenIds`. +- Keep runtime rebuild behavior unchanged after saving credentials or enabled state. + +## Affected Interfaces +- `saveFeishuSettings(input: FeishuRemoteSettings)` behavior changes to ignore `input.pairedUserOpenIds` for persistence. +- Returned settings remain `FeishuRemoteSettings` and include the current paired users from storage. + +## Data Flow +- Frontend sends normalized Feishu settings, possibly from stale form state. +- Main process updates editable Feishu config fields. +- Existing `pairedUserOpenIds` is copied from the current config, so pairing state created by command handling is preserved. + +## Compatibility +- No IPC or renderer contract changes. +- Old frontend payloads with `pairedUserOpenIds` continue to be accepted but cannot mutate authorization state. + +## Test Strategy +- Add a main presenter unit test that seeds a paired Feishu user, saves settings with `pairedUserOpenIds: []`, and expects the paired user to remain. +- Run focused remote control presenter tests, then required format/i18n/lint commands if feasible. diff --git a/docs/issues/feishu-pairing-save-race/spec.md b/docs/issues/feishu-pairing-save-race/spec.md new file mode 100644 index 000000000..cb944c9c9 --- /dev/null +++ b/docs/issues/feishu-pairing-save-race/spec.md @@ -0,0 +1,21 @@ +# Feishu Pairing Save Race + +## User Need +Feishu/Lark Remote Control users who complete `/pair ` must stay authorized after the settings page auto-saves stale form state. + +## Goal +Prevent general Feishu settings saves from overwriting the runtime-managed `pairedUserOpenIds` list. + +## Acceptance Criteria +- Saving Feishu settings preserves the existing paired user open IDs when the input contains an older or empty list. +- Pair/unpair operations remain the only path that changes Feishu paired users. +- Existing Feishu settings fields such as brand, credentials, enabled state, default agent, and workdir still save normally. +- Regression coverage verifies stale frontend settings cannot erase a paired Feishu user. + +## Constraints +- Keep public settings shape compatible with the current renderer and IPC contracts. +- Avoid broad changes to unrelated remote channels unless a matching save-race path is confirmed in code. + +## Non-Goals +- Redesign remote settings state management. +- Remove `pairedUserOpenIds` from shared types in this fix. diff --git a/docs/issues/feishu-pairing-save-race/tasks.md b/docs/issues/feishu-pairing-save-race/tasks.md new file mode 100644 index 000000000..256ec5755 --- /dev/null +++ b/docs/issues/feishu-pairing-save-race/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Capture issue requirements and SDD artifacts. +- [x] Change Feishu settings save to preserve paired users. +- [x] Add regression test for stale Feishu settings save. +- [x] Run formatting, i18n generation, lint, and focused tests. diff --git a/docs/issues/scheduled-task-prompt-picker-ux/plan.md b/docs/issues/scheduled-task-prompt-picker-ux/plan.md new file mode 100644 index 000000000..f4da3c510 --- /dev/null +++ b/docs/issues/scheduled-task-prompt-picker-ux/plan.md @@ -0,0 +1,25 @@ +# Plan + +## Approach + +- Keep the fix scoped to `src/renderer/settings/components/ScheduledTasksSettings.vue`. +- Make the settings page feel less cramped by separating the page header, empty state, task header, trigger panel, and action panel with clearer surfaces and spacing. +- Simplify visual weight by using softer panels, clearer section headers, and responsive control groups instead of dense nested card chrome. +- Keep each task card scannable with a compact status/summary row, a prominent editable name, and action buttons that wrap cleanly on smaller widths. +- Change the prompt action control grid so each column can shrink (`min-w-0`) and stacks earlier when space is tight. +- Force select triggers/buttons to use `w-full min-w-0` and truncate visible labels. +- Replace the model ID input with a `Popover` + existing `ModelSelect` picker. +- Resolve the displayed model name through `useModelStore.enabledModels`, with a fallback to the stored model ID. +- Close the model popover after selection and persist via the existing task upsert path. + +## Data Flow + +- Existing task action stores `providerId` and `modelId`. +- Agent selection may set both fields from `agent.config.defaultModelPreset`. +- Model picker selection updates `providerId` and `modelId` explicitly. + +## Validation + +- Run formatting and lint checks required by repository guidelines. +- Run i18n validation and ensure any new user-facing text is backed by locale keys. +- Review the changed template for responsive truncation and no new raw strings. diff --git a/docs/issues/scheduled-task-prompt-picker-ux/spec.md b/docs/issues/scheduled-task-prompt-picker-ux/spec.md new file mode 100644 index 000000000..ec152f22d --- /dev/null +++ b/docs/issues/scheduled-task-prompt-picker-ux/spec.md @@ -0,0 +1,28 @@ +# Scheduled Task Prompt Picker UX + +## User Story + +As a user configuring scheduled tasks, I want the task list to feel clean, balanced, and easy to scan, and I want prompt agent/model controls to fit cleanly in the task card with selectable options, so I can configure automation without overlapping fields or manually typing model IDs. + +## Acceptance Criteria + +- Scheduled task settings use a cleaner, more balanced layout with readable hierarchy between page header, task header, trigger settings, and action settings. +- Task cards avoid overly heavy nested borders and keep controls aligned in a responsive two-panel composition. +- Agent and model controls in prompt actions do not overlap at common settings window widths. +- Long agent names or IDs are truncated inside their control instead of expanding the grid column. +- The model field is a selectable model picker populated from enabled models, not a free-form text input. +- Selecting an agent still applies its default model preset when available. +- Selecting a model persists both `providerId` and `modelId` for the scheduled task. +- Existing notify actions, trigger controls, and prompt text fields keep their current behavior. + +## Non-Goals + +- No changes to scheduled task execution semantics. +- No new model filtering rules beyond excluding ACP and using enabled models. +- No new IPC or persistence schema changes. + +## Constraints + +- Follow existing Vue 3 Composition API and shadcn/Tailwind patterns. +- Reuse existing model picker components where possible. +- Avoid new user-facing strings unless required. diff --git a/docs/issues/scheduled-task-prompt-picker-ux/tasks.md b/docs/issues/scheduled-task-prompt-picker-ux/tasks.md new file mode 100644 index 000000000..72df93019 --- /dev/null +++ b/docs/issues/scheduled-task-prompt-picker-ux/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Locate scheduled task prompt action UI. +- [x] Specify layout and model picker acceptance criteria. +- [x] Update prompt action agent/model controls. +- [x] Refine scheduled task card layout and i18n-backed UI copy. +- [x] Run required format/i18n/lint checks. +- [x] Rebalance scheduled task settings layout surfaces and spacing. +- [x] Re-run i18n validation after layout changes. diff --git a/docs/issues/scheduled-tasks-clone-error/plan.md b/docs/issues/scheduled-tasks-clone-error/plan.md new file mode 100644 index 000000000..2423a46d4 --- /dev/null +++ b/docs/issues/scheduled-tasks-clone-error/plan.md @@ -0,0 +1,5 @@ +# Implementation Plan + +- Convert scheduled task trigger/action values to plain objects before invoking the scheduled task IPC route. +- Keep the change local to `ScheduledTasksSettings.vue` so route contracts and main-process logic remain unchanged. +- Validate with formatting, typecheck, lint, and the focused scheduled tasks test suite. diff --git a/docs/issues/scheduled-tasks-clone-error/spec.md b/docs/issues/scheduled-tasks-clone-error/spec.md new file mode 100644 index 000000000..7d79ce8f7 --- /dev/null +++ b/docs/issues/scheduled-tasks-clone-error/spec.md @@ -0,0 +1,13 @@ +# Scheduled Tasks Clone Error + +## Problem + +Editing a scheduled task in Settings can fail with `Error: An object could not be cloned.` from `ScheduledTasksSettings.vue` when persisting a task. + +## Cause + +The settings page stores tasks in Vue reactive state. `persistTask` forwards `task.trigger` and `task.action` directly to the IPC client. Those nested objects can be Vue proxies, which are not structured-cloneable by Electron IPC. + +## Expected Behavior + +Persisting an edited scheduled task sends a plain serializable payload to the route and succeeds without clone errors. diff --git a/docs/issues/scheduled-tasks-clone-error/tasks.md b/docs/issues/scheduled-tasks-clone-error/tasks.md new file mode 100644 index 000000000..57f1de2d9 --- /dev/null +++ b/docs/issues/scheduled-tasks-clone-error/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add SDD issue artifacts. +- [x] Serialize scheduled task edit payloads before IPC. +- [x] Run focused scheduled tasks test and typecheck. +- [x] Run i18n and lint. diff --git a/docs/issues/scheduled-tasks-real-dispatch/plan.md b/docs/issues/scheduled-tasks-real-dispatch/plan.md new file mode 100644 index 000000000..4b9af0599 --- /dev/null +++ b/docs/issues/scheduled-tasks-real-dispatch/plan.md @@ -0,0 +1,6 @@ +# Implementation Plan + +- Reuse `ChatService` in the route runtime and call `sendMessage` after creating a scheduled task session. +- Load agents with `ConfigClient.listAgents()` in `ScheduledTasksSettings.vue`. +- Replace the raw agent ID input with an agent select and persist the selected agent plus its default provider/model preset. +- Keep manual model override available for users who need it. diff --git a/docs/issues/scheduled-tasks-real-dispatch/spec.md b/docs/issues/scheduled-tasks-real-dispatch/spec.md new file mode 100644 index 000000000..17ab86996 --- /dev/null +++ b/docs/issues/scheduled-tasks-real-dispatch/spec.md @@ -0,0 +1,11 @@ +# Scheduled Tasks Real Dispatch + +## Problem + +Scheduled task prompt actions feel fake: `autoSend` creates a session but does not actually send the prompt to the agent, and the settings UI requires users to type raw agent/model IDs by hand. + +## Expected Behavior + +- `autoSend` creates a session and sends the configured message through the normal chat pipeline. +- The settings UI lists real enabled agents so users can select a target instead of guessing IDs. +- Selecting an agent carries over its default model preset when available. diff --git a/docs/issues/scheduled-tasks-real-dispatch/tasks.md b/docs/issues/scheduled-tasks-real-dispatch/tasks.md new file mode 100644 index 000000000..155644b87 --- /dev/null +++ b/docs/issues/scheduled-tasks-real-dispatch/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add SDD issue artifacts. +- [x] Send prompt after scheduled auto-send session creation. +- [x] Use real enabled agents in scheduled task settings. +- [x] Run formatting, lint, typecheck, i18n, and focused tests. diff --git a/package.json b/package.json index e2ddb28ca..8fbfc7f9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "1.0.5-beta.4", + "version": "1.0.5-beta.5", "description": "DeepChat,一个简单易用的 Agent 客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 43b066ea5..fc79b50df 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -89,6 +89,11 @@ import { createDefaultHooksNotificationsConfig, normalizeHooksNotificationsConfig } from '../hooksNotifications/config' +import { normalizeScheduledTasksConfig } from '../scheduledTasks/normalize' +import { + createDefaultScheduledTasksSettings, + type ScheduledTasksSettings +} from '@shared/scheduledTasks' import { AcpDbStore, AppSettingsDbBackedStore, @@ -131,6 +136,7 @@ interface IAppSettings { enableSkills?: boolean // Skills system global toggle skillDraftSuggestionsEnabled?: boolean // Whether agent may propose skill drafts after tasks hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings + scheduledTasks?: ScheduledTasksSettings // User-defined scheduled tasks defaultModel?: { providerId: string; modelId: string } // Default model for new conversations defaultVisionModel?: { providerId: string; modelId: string } // Legacy vision model setting for migration only defaultProjectPath?: string | null @@ -434,7 +440,8 @@ export class ConfigPresenter implements IConfigPresenter { skillDraftSuggestionsEnabled: false, updateChannel: 'stable', // Default to stable version appVersion: this.currentAppVersion, - hooksNotifications: createDefaultHooksNotificationsConfig() + hooksNotifications: createDefaultHooksNotificationsConfig(), + scheduledTasks: createDefaultScheduledTasksSettings() } }) @@ -1757,7 +1764,15 @@ export class ConfigPresenter implements IConfigPresenter { 'fa-IR', 'pt-BR', 'da-DK', - 'he-IL' + 'he-IL', + 'es-ES', + 'de-DE', + 'tr-TR', + 'id-ID', + 'ms-MY', + 'it-IT', + 'pl-PL', + 'vi-VN' ] // Exact match @@ -3183,6 +3198,21 @@ export class ConfigPresenter implements IConfigPresenter { return normalized } + getScheduledTasksConfig(): ScheduledTasksSettings { + const raw = this.store.get('scheduledTasks') + const normalized = normalizeScheduledTasksConfig(raw) + if (!raw || JSON.stringify(raw) !== JSON.stringify(normalized)) { + this.store.set('scheduledTasks', normalized) + } + return normalized + } + + setScheduledTasksConfig(config: ScheduledTasksSettings): ScheduledTasksSettings { + const normalized = normalizeScheduledTasksConfig(config) + this.store.set('scheduledTasks', normalized) + return normalized + } + async testHookCommand(hookId: string): Promise { return await presenter.hooksNotifications.testHookCommand(hookId) } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 7729f1372..3825633b7 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -64,6 +64,7 @@ import type { SkillSessionStatePort } from './skillPresenter' import { SkillSyncPresenter } from './skillSyncPresenter' import { HooksNotificationsService } from './hooksNotifications' import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' +import { ScheduledTasksService } from './scheduledTasks' import { AgentSessionPresenter } from './agentSessionPresenter' import { AgentRuntimePresenter } from './agentRuntimePresenter' import { ProjectPresenter } from './projectPresenter' @@ -191,6 +192,7 @@ export class Presenter implements IPresenter { pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter hooksNotifications: HooksNotificationsService + scheduledTasks: ScheduledTasksService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService settingsPermissionService: SettingsPermissionService @@ -486,6 +488,11 @@ export class Presenter implements IPresenter { getSession: async () => null, getMessage: async () => null }) + this.scheduledTasks = new ScheduledTasksService({ + configPresenter: this.configPresenter, + notificationPresenter: this.notificationPresenter, + windowPresenter: this.windowPresenter + }) const newSessionHooksBridge = new NewSessionHooksBridge(this.hooksNotifications) const providerCatalogPort: ProviderCatalogPort = { getProviderModels: (providerId) => this.configPresenter.getProviderModels?.(providerId) ?? [], @@ -944,6 +951,41 @@ export class Presenter implements IPresenter { export let presenter: Presenter let cachedMainKernelRouteRuntime: ReturnType | undefined +const buildMainKernelRouteRuntime = () => + createMainKernelRouteRuntime({ + configPresenter: presenter.configPresenter, + llmProviderPresenter: presenter.llmproviderPresenter, + agentSessionPresenter: presenter.agentSessionPresenter, + skillPresenter: presenter.skillPresenter, + mcpPresenter: presenter.mcpPresenter, + syncPresenter: presenter.syncPresenter, + upgradePresenter: presenter.upgradePresenter, + dialogPresenter: presenter.dialogPresenter, + toolPresenter: presenter.toolPresenter, + sqlitePresenter: presenter.sqlitePresenter, + windowPresenter: presenter.windowPresenter, + devicePresenter: presenter.devicePresenter, + projectPresenter: presenter.projectPresenter, + filePresenter: presenter.filePresenter, + workspacePresenter: presenter.workspacePresenter, + yoBrowserPresenter: presenter.yoBrowserPresenter, + tabPresenter: presenter.tabPresenter, + startupWorkloadCoordinator: presenter.startupWorkloadCoordinator, + pluginPresenter: presenter.pluginPresenter, + databaseSecurityPresenter: presenter.databaseSecurityPresenter, + scheduledTasks: presenter.scheduledTasks + }) + +export function getMainKernelRouteRuntime(): ReturnType { + if (!presenter) { + throw new Error('Presenter must be initialized before accessing the kernel route runtime') + } + if (!cachedMainKernelRouteRuntime) { + cachedMainKernelRouteRuntime = buildMainKernelRouteRuntime() + } + return cachedMainKernelRouteRuntime +} + // Initialize presenter with database instance and optional lifecycle manager export function getInstance(lifecycleManager: ILifecycleManager): Presenter { // only allow initialize once @@ -955,32 +997,7 @@ export function getInstance(lifecycleManager: ILifecycleManager): Presenter { return presenter } -registerMainKernelRoutes(ipcMain, () => - presenter - ? (cachedMainKernelRouteRuntime ??= createMainKernelRouteRuntime({ - configPresenter: presenter.configPresenter, - llmProviderPresenter: presenter.llmproviderPresenter, - agentSessionPresenter: presenter.agentSessionPresenter, - skillPresenter: presenter.skillPresenter, - mcpPresenter: presenter.mcpPresenter, - syncPresenter: presenter.syncPresenter, - upgradePresenter: presenter.upgradePresenter, - dialogPresenter: presenter.dialogPresenter, - toolPresenter: presenter.toolPresenter, - sqlitePresenter: presenter.sqlitePresenter, - windowPresenter: presenter.windowPresenter, - devicePresenter: presenter.devicePresenter, - projectPresenter: presenter.projectPresenter, - filePresenter: presenter.filePresenter, - workspacePresenter: presenter.workspacePresenter, - yoBrowserPresenter: presenter.yoBrowserPresenter, - tabPresenter: presenter.tabPresenter, - startupWorkloadCoordinator: presenter.startupWorkloadCoordinator, - pluginPresenter: presenter.pluginPresenter, - databaseSecurityPresenter: presenter.databaseSecurityPresenter - })) - : undefined -) +registerMainKernelRoutes(ipcMain, () => (presenter ? getMainKernelRouteRuntime() : undefined)) // 检查对象属性是否为函数 (用于动态调用) // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts new file mode 100644 index 000000000..8fb15ec0e --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/scheduledTasksStartHook.ts @@ -0,0 +1,38 @@ +/** + * Scheduled tasks start hook for after-start phase + * + * The route runtime owns the wiring between the scheduled tasks service and + * the session service (for auto-send actions), so we force its construction + * by reading any route runtime via getRuntime, then call `start()` so the + * scheduler arms timers and backfills missed one-shot tasks. + */ + +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter, getMainKernelRouteRuntime } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const scheduledTasksStartHook: LifecycleHook = { + name: 'scheduled-tasks-start', + phase: LifecyclePhase.AFTER_START, + priority: 20, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + throw new Error('scheduledTasksStartHook: Presenter not initialized') + } + + // Touch the route runtime so the session creator gets wired up before + // the scheduler fires anything. + try { + getMainKernelRouteRuntime() + } catch (error) { + console.warn( + '[scheduledTasksStartHook] Failed to prime route runtime; auto-send may degrade to draft mode:', + error + ) + } + + presenter.scheduledTasks.start() + console.log('scheduledTasksStartHook: Scheduler started') + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts new file mode 100644 index 000000000..6012c92a7 --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/scheduledTasksStopHook.ts @@ -0,0 +1,21 @@ +/** + * Scheduled tasks stop hook for beforeQuit phase + * Cancels all armed timers so the scheduler does not fire during shutdown. + */ + +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { presenter } from '@/presenter' +import { LifecyclePhase } from '@shared/lifecycle' + +export const scheduledTasksStopHook: LifecycleHook = { + name: 'scheduled-tasks-stop', + phase: LifecyclePhase.BEFORE_QUIT, + priority: 30, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + return + } + presenter.scheduledTasks.stop() + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/index.ts b/src/main/presenter/lifecyclePresenter/hooks/index.ts index 85493bd8f..59a419324 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/index.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/index.ts @@ -15,9 +15,11 @@ export { legacyImportHook } from './after-start/legacyImportHook' export { rtkHealthCheckHook } from './after-start/rtkHealthCheckHook' export { usageStatsBackfillHook } from './after-start/usageStatsBackfillHook' export { sqliteMainlineNormalizationHook } from './after-start/sqliteMainlineNormalizationHook' +export { scheduledTasksStartHook } from './after-start/scheduledTasksStartHook' export { trayDestroyHook } from './beforeQuit/trayDestroyHook' export { floatingDestroyHook } from './beforeQuit/floatingDestroyHook' export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook' export { builtinKnowledgeDestroyHook } from './beforeQuit/builtinKnowledgeDestroyHook' export { windowQuittingHook } from './beforeQuit/windowQuittingHook' export { acpCleanupHook } from './beforeQuit/acpCleanupHook' +export { scheduledTasksStopHook } from './beforeQuit/scheduledTasksStopHook' diff --git a/src/main/presenter/llmProviderPresenter/aiSdk/runtime.ts b/src/main/presenter/llmProviderPresenter/aiSdk/runtime.ts index bf5cf959b..6c3db6f9e 100644 --- a/src/main/presenter/llmProviderPresenter/aiSdk/runtime.ts +++ b/src/main/presenter/llmProviderPresenter/aiSdk/runtime.ts @@ -403,6 +403,22 @@ function extractTtsText(messages: ChatMessage[]): string { return '' } +function extractChatAudioContentData(content: unknown): string | undefined { + if (!Array.isArray(content)) { + return undefined + } + + const audioPart = content.find( + (item) => item && typeof item === 'object' && 'type' in item && item.type === 'audio' + ) + const audioData = + audioPart && typeof audioPart === 'object' && 'audio' in audioPart + ? (audioPart.audio as { data?: unknown } | undefined)?.data + : undefined + + return typeof audioData === 'string' && audioData ? audioData : undefined +} + /** * Pattern A: calls the standard OpenAI-compatible /audio/speech endpoint. */ @@ -521,15 +537,15 @@ async function executeTtsPatternB( const json = (await response.json()) as { choices?: Array<{ message?: { - audio?: { data?: string } - content?: Array<{ type?: string; audio?: { data?: string } }> + audio?: { data?: unknown } + content?: unknown } }> } const firstMessage = json.choices?.[0]?.message - const audioData = - firstMessage?.audio?.data ?? - firstMessage?.content?.find((item) => item?.type === 'audio')?.audio?.data + const directAudioData = + typeof firstMessage?.audio?.data === 'string' ? firstMessage.audio.data : undefined + const audioData = directAudioData ?? extractChatAudioContentData(firstMessage?.content) if (!audioData) { throw new Error('TTS response missing audio data in choices[0].message.audio.data') } diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 34ef25602..3180a0e11 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -532,7 +532,7 @@ export class RemoteControlPresenter { enabled: normalized.remoteEnabled, defaultAgentId, defaultWorkdir: normalized.defaultWorkdir, - pairedUserOpenIds: normalized.pairedUserOpenIds, + pairedUserOpenIds: config.pairedUserOpenIds, lastFatalError: shouldClearFatalError ? null : config.lastFatalError, pairing: config.pairing })) diff --git a/src/main/presenter/scheduledTasks/index.ts b/src/main/presenter/scheduledTasks/index.ts new file mode 100644 index 000000000..aad36b2d5 --- /dev/null +++ b/src/main/presenter/scheduledTasks/index.ts @@ -0,0 +1,345 @@ +import { randomUUID } from 'node:crypto' +import log from 'electron-log' +import type { IConfigPresenter, INotificationPresenter, IWindowPresenter } from '@shared/presenter' +import { DEEPLINK_EVENTS } from '@/events' +import { + SCHEDULED_TASKS_VERSION, + SCHEDULED_TASK_DEFAULT_AGENT_ID, + type ScheduledTask, + type ScheduledTaskAction, + type ScheduledTasksSettings +} from '@shared/scheduledTasks' +import type { z } from 'zod' +import { + scheduledTaskActionSchema, + scheduledTaskTriggerSchema, + type scheduledTasksUpsertInputSchema +} from '@shared/contracts/routes/scheduledTasks.routes' +import { computeNextFireAt, shouldBackfillOneShot } from './normalize' + +const MAX_TIMEOUT_MS = 12 * 60 * 60 * 1000 // 12h chained-timeout cap +const RECENT_DRIFT_TOLERANCE_MS = 60 * 1000 // forgive up to 1m clock drift + +export type ScheduledTasksUpsertInput = z.input + +interface SessionCreator { + createSessionForTask(input: { + agentId: string + message: string + providerId?: string + modelId?: string + systemPrompt?: string + }): Promise<{ sessionId: string | null }> +} + +export interface ScheduledTasksServiceDeps { + configPresenter: Pick< + IConfigPresenter, + 'getScheduledTasksConfig' | 'setScheduledTasksConfig' | 'getNotificationsEnabled' + > + notificationPresenter: Pick + windowPresenter: Pick & { + mainWindow: IWindowPresenter['mainWindow'] + } + sessionCreator?: SessionCreator +} + +export class ScheduledTasksService { + private readonly configPresenter: ScheduledTasksServiceDeps['configPresenter'] + private readonly notificationPresenter: ScheduledTasksServiceDeps['notificationPresenter'] + private readonly windowPresenter: ScheduledTasksServiceDeps['windowPresenter'] + private sessionCreator: SessionCreator | null + private readonly timers = new Map() + private started = false + + constructor(deps: ScheduledTasksServiceDeps) { + this.configPresenter = deps.configPresenter + this.notificationPresenter = deps.notificationPresenter + this.windowPresenter = deps.windowPresenter + this.sessionCreator = deps.sessionCreator ?? null + } + + setSessionCreator(creator: SessionCreator | null): void { + this.sessionCreator = creator + } + + start(): void { + if (this.started) { + return + } + this.started = true + this.runStartupPass() + } + + stop(): void { + this.started = false + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + } + + list(): ScheduledTasksSettings { + return this.configPresenter.getScheduledTasksConfig() + } + + upsert(input: ScheduledTasksUpsertInput): { + task: ScheduledTask + settings: ScheduledTasksSettings + } { + const now = Date.now() + const current = this.list() + const existingIndex = input.id ? current.tasks.findIndex((task) => task.id === input.id) : -1 + const existing = existingIndex >= 0 ? current.tasks[existingIndex] : null + + const trigger = scheduledTaskTriggerSchema.parse(input.trigger) + const action = scheduledTaskActionSchema.parse(input.action) + + const triggerChanged = !existing || JSON.stringify(existing.trigger) !== JSON.stringify(trigger) + + const task: ScheduledTask = { + id: existing?.id ?? input.id ?? randomUUID(), + name: input.name, + enabled: input.enabled, + trigger, + action, + createdAt: existing?.createdAt ?? now, + // Reset lastFiredAt when the trigger changes so a rescheduled one-shot + // doesn't get skipped on the assumption it has already run. + lastFiredAt: triggerChanged ? null : (existing?.lastFiredAt ?? null) + } + + const tasks = + existingIndex >= 0 + ? current.tasks.map((value, index) => (index === existingIndex ? task : value)) + : [...current.tasks, task] + + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + + this.cancel(task.id) + if (task.enabled) { + this.armTask(task, Date.now()) + } + + return { task, settings } + } + + delete(id: string): ScheduledTasksSettings { + const current = this.list() + const next = current.tasks.filter((task) => task.id !== id) + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks: next }) + this.cancel(id) + return settings + } + + toggle(id: string, enabled: boolean): { task: ScheduledTask; settings: ScheduledTasksSettings } { + const current = this.list() + const existing = current.tasks.find((task) => task.id === id) + if (!existing) { + throw new Error(`Unknown scheduled task: ${id}`) + } + const updated: ScheduledTask = { ...existing, enabled } + const tasks = current.tasks.map((task) => (task.id === id ? updated : task)) + const settings = this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + + this.cancel(id) + if (enabled) { + this.armTask(updated, Date.now()) + } + + return { task: updated, settings } + } + + async fireNow(id: string): Promise<{ task: ScheduledTask; settings: ScheduledTasksSettings }> { + const current = this.list() + const existing = current.tasks.find((task) => task.id === id) + if (!existing) { + throw new Error(`Unknown scheduled task: ${id}`) + } + await this.dispatch(existing) + const settings = this.markFired(existing) + const refreshed = settings.tasks.find((task) => task.id === id) ?? existing + return { task: refreshed, settings } + } + + private runStartupPass(): void { + const now = Date.now() + const settings = this.list() + for (const task of settings.tasks) { + if (!task.enabled) { + continue + } + if (shouldBackfillOneShot(task, now)) { + void this.fireAndPersist(task) + continue + } + this.armTask(task, now) + } + } + + private armTask(task: ScheduledTask, now: number): void { + const nextFireAt = computeNextFireAt(task, now - RECENT_DRIFT_TOLERANCE_MS) + if (!nextFireAt) { + return + } + + const delay = Math.max(0, nextFireAt - now) + if (delay > MAX_TIMEOUT_MS) { + const timer = setTimeout(() => { + this.timers.delete(task.id) + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (refreshed?.enabled) { + this.armTask(refreshed, Date.now()) + } + }, MAX_TIMEOUT_MS) + this.timers.set(task.id, timer) + return + } + + const timer = setTimeout(() => { + this.timers.delete(task.id) + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (!refreshed || !refreshed.enabled) { + return + } + void this.fireAndPersist(refreshed) + }, delay) + this.timers.set(task.id, timer) + } + + private cancel(id: string): void { + const timer = this.timers.get(id) + if (timer) { + clearTimeout(timer) + this.timers.delete(id) + } + } + + private persist(settings: ScheduledTasksSettings): ScheduledTasksSettings { + return this.configPresenter.setScheduledTasksConfig(settings) + } + + private markFired(task: ScheduledTask): ScheduledTasksSettings { + const current = this.list() + const tasks = current.tasks.map((entry) => { + if (entry.id !== task.id) { + return entry + } + // One-shot tasks auto-disable on fire so the user notices and can + // either delete or reschedule. + const disable = entry.trigger.kind === 'once' + return { + ...entry, + lastFiredAt: Date.now(), + enabled: disable ? false : entry.enabled + } + }) + return this.persist({ version: SCHEDULED_TASKS_VERSION, tasks }) + } + + private async fireAndPersist(task: ScheduledTask): Promise { + try { + await this.dispatch(task) + } catch (error) { + log.error('[ScheduledTasks] Dispatch failed:', error) + } finally { + this.markFired(task) + if (task.trigger.kind !== 'once') { + // Re-arm for the next recurring slot using the just-persisted state. + const refreshed = this.list().tasks.find((entry) => entry.id === task.id) + if (refreshed?.enabled) { + this.armTask(refreshed, Date.now()) + } + } + } + } + + private async dispatch(task: ScheduledTask): Promise { + await this.runAction(task.id, task.action) + } + + private async runAction(taskId: string, action: ScheduledTaskAction): Promise { + switch (action.kind) { + case 'notify': + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.body + }) + return + case 'prompt': + if (action.autoSend) { + await this.runPromptAutoSend(taskId, action) + return + } + await this.runPromptDraft(taskId, action) + return + default: { + const _exhaustive: never = action + throw new Error(`[ScheduledTasks] Unhandled action kind: ${String(_exhaustive)}`) + } + } + } + + private async runPromptDraft( + taskId: string, + action: Extract + ): Promise { + const target = this.windowPresenter.mainWindow + if (target && !target.isDestroyed()) { + this.windowPresenter.sendToWindow(target.id, DEEPLINK_EVENTS.START, { + msg: action.message, + modelId: action.modelId ?? null, + systemPrompt: action.systemPrompt ?? '', + mentions: [], + autoSend: false + }) + this.windowPresenter.focusMainWindow() + } else { + log.warn('[ScheduledTasks] No main window available for prompt draft action') + } + + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.message.slice(0, 200) + }) + } + + private async runPromptAutoSend( + taskId: string, + action: Extract + ): Promise { + if (!this.sessionCreator) { + log.warn('[ScheduledTasks] sessionCreator is not wired; falling back to draft mode') + await this.runPromptDraft(taskId, action) + return + } + + try { + await this.sessionCreator.createSessionForTask({ + agentId: action.agentId ?? SCHEDULED_TASK_DEFAULT_AGENT_ID, + message: action.message, + providerId: action.providerId, + modelId: action.modelId, + systemPrompt: action.systemPrompt + }) + + await this.notificationPresenter.showNotification({ + id: `scheduled:${taskId}`, + title: action.title, + body: action.message.slice(0, 200) + }) + } catch (error) { + log.error('[ScheduledTasks] Failed to create session for task:', error) + // Fall back so the user still sees something happened. + await this.runPromptDraft(taskId, action) + } + } +} + +export { + computeNextFireAt, + normalizeScheduledTasksConfig, + shouldBackfillOneShot +} from './normalize' diff --git a/src/main/presenter/scheduledTasks/normalize.ts b/src/main/presenter/scheduledTasks/normalize.ts new file mode 100644 index 000000000..b4ad15951 --- /dev/null +++ b/src/main/presenter/scheduledTasks/normalize.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' +import log from 'electron-log' +import { z } from 'zod' +import { + SCHEDULED_TASKS_VERSION, + type ScheduledTask, + type ScheduledTaskAction, + type ScheduledTaskTrigger, + type ScheduledTasksSettings, + createDefaultScheduledTasksSettings +} from '@shared/scheduledTasks' + +const TriggerSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('once'), firesAt: z.number().int().nonnegative() }), + z.object({ + kind: z.literal('daily'), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59) + }), + z.object({ + kind: z.literal('weekly'), + dayOfWeek: z.number().int().min(0).max(6), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59) + }) +]) + +const ActionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('notify'), + title: z.string().max(200), + body: z.string().max(2000) + }), + z.object({ + kind: z.literal('prompt'), + title: z.string().max(200), + message: z.string().max(20000), + autoSend: z.boolean(), + agentId: z.string().optional(), + providerId: z.string().optional(), + modelId: z.string().optional(), + systemPrompt: z.string().max(20000).optional() + }) +]) + +const ScheduledTaskSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(200), + enabled: z.boolean(), + trigger: TriggerSchema, + action: ActionSchema, + createdAt: z.number().int().nonnegative(), + lastFiredAt: z.number().int().nonnegative().nullable() +}) + +const LooseSchedulerSettingsSchema = z + .object({ + version: z.unknown().optional(), + tasks: z.array(z.unknown()).optional() + }) + .strip() + +const sanitizeTrigger = (input: unknown): ScheduledTaskTrigger | null => { + const parsed = TriggerSchema.safeParse(input) + return parsed.success ? parsed.data : null +} + +const sanitizeAction = (input: unknown): ScheduledTaskAction | null => { + const parsed = ActionSchema.safeParse(input) + return parsed.success ? parsed.data : null +} + +const sanitizeTask = (input: unknown, fallbackIndex: number, now: number): ScheduledTask | null => { + if (!input || typeof input !== 'object') { + return null + } + const record = input as Record + const trigger = sanitizeTrigger(record.trigger) + const action = sanitizeAction(record.action) + if (!trigger || !action) { + return null + } + + const id = + typeof record.id === 'string' && record.id.trim().length > 0 ? record.id.trim() : randomUUID() + const name = + typeof record.name === 'string' && record.name.trim().length > 0 + ? record.name.trim().slice(0, 200) + : `Task ${fallbackIndex + 1}` + const enabled = record.enabled === true + const createdAt = + typeof record.createdAt === 'number' && + Number.isFinite(record.createdAt) && + record.createdAt > 0 + ? record.createdAt + : now + const lastFiredAt = + typeof record.lastFiredAt === 'number' && + Number.isFinite(record.lastFiredAt) && + record.lastFiredAt > 0 + ? record.lastFiredAt + : null + + const candidate = { id, name, enabled, trigger, action, createdAt, lastFiredAt } + const parsed = ScheduledTaskSchema.safeParse(candidate) + return parsed.success ? parsed.data : null +} + +const makeUniqueTaskId = (id: string, seenIds: Set): string => { + if (!seenIds.has(id)) { + return id + } + + let suffix = 2 + let nextId = `${id}-${suffix}` + while (seenIds.has(nextId)) { + suffix += 1 + nextId = `${id}-${suffix}` + } + return nextId +} + +export const normalizeScheduledTasksConfig = ( + input: unknown, + now: number = Date.now() +): ScheduledTasksSettings => { + const defaults = createDefaultScheduledTasksSettings() + const parsed = LooseSchedulerSettingsSchema.safeParse(input) + if (!parsed.success) { + log.warn('[ScheduledTasks] Invalid config, using defaults:', parsed.error?.message) + return defaults + } + + const rawTasks = Array.isArray(parsed.data.tasks) ? parsed.data.tasks : [] + const seenIds = new Set() + const tasks = rawTasks.reduce((acc, candidate, index) => { + const sanitized = sanitizeTask(candidate, index, now) + if (sanitized) { + const id = makeUniqueTaskId(sanitized.id, seenIds) + seenIds.add(id) + acc.push(id === sanitized.id ? sanitized : { ...sanitized, id }) + } else { + log.warn(`[ScheduledTasks] Dropping malformed task at index ${index}`) + } + return acc + }, []) + + return { + version: SCHEDULED_TASKS_VERSION, + tasks + } +} + +const startOfMinute = (timestamp: number): number => { + const date = new Date(timestamp) + date.setSeconds(0, 0) + return date.getTime() +} + +const buildWallClockToday = ( + reference: number, + hour: number, + minute: number, + dayOffset = 0 +): number => { + const date = new Date(reference) + date.setDate(date.getDate() + dayOffset) + date.setHours(hour, minute, 0, 0) + return date.getTime() +} + +/** + * Compute the next absolute timestamp at which `task` should fire, strictly + * after `after`. Returns `null` if the task can no longer fire (one-shot + * already fired or one-shot whose `firesAt` is in the past with respect to + * `after` — backfill handling is up to the caller via `lastFiredAt`). + */ +export const computeNextFireAt = (task: ScheduledTask, after: number): number | null => { + const trigger = task.trigger + switch (trigger.kind) { + case 'once': { + if (task.lastFiredAt) { + return null + } + return trigger.firesAt > after ? trigger.firesAt : null + } + case 'daily': { + let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 0) + if (candidate <= after) { + candidate = buildWallClockToday(after, trigger.hour, trigger.minute, 1) + } + return candidate + } + case 'weekly': { + const reference = new Date(after) + const currentDay = reference.getDay() + let dayOffset = (trigger.dayOfWeek - currentDay + 7) % 7 + let candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) + if (candidate <= after) { + dayOffset += 7 + candidate = buildWallClockToday(after, trigger.hour, trigger.minute, dayOffset) + } + return candidate + } + default: + return null + } +} + +/** + * Returns true when a one-shot task should be backfilled (fired immediately + * on startup) because its `firesAt` is in the past and it has never been + * fired. Recurring tasks are never backfilled. + */ +export const shouldBackfillOneShot = (task: ScheduledTask, now: number): boolean => { + if (task.trigger.kind !== 'once') { + return false + } + if (task.lastFiredAt) { + return false + } + return task.trigger.firesAt <= now +} + +export const startOfMinuteForTests = startOfMinute diff --git a/src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts b/src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts index ccab337f5..1b5a056dc 100644 --- a/src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts +++ b/src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts @@ -35,7 +35,15 @@ const SUPPORTED_LANGUAGES = [ 'fa-IR', 'pt-BR', 'da-DK', - 'he-IL' + 'he-IL', + 'es-ES', + 'de-DE', + 'tr-TR', + 'id-ID', + 'ms-MY', + 'it-IT', + 'pl-PL', + 'vi-VN' ] as const satisfies readonly ChatLanguage[] const SUPPORTED_THEMES = ['dark', 'light', 'system'] as const diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index 4518f0be1..fc0e7ddf1 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -243,6 +243,14 @@ import type { StartupWorkloadCoordinator } from '@/presenter/startupWorkloadCoor import type { PluginPresenter } from '@/presenter/pluginPresenter' import type { DatabaseSecurityPresenter } from '@/presenter/databaseSecurityPresenter' import type { SQLitePresenter } from '@/presenter/sqlitePresenter' +import type { ScheduledTasksService } from '@/presenter/scheduledTasks' +import { + scheduledTasksDeleteRoute, + scheduledTasksFireNowRoute, + scheduledTasksListRoute, + scheduledTasksToggleRoute, + scheduledTasksUpsertRoute +} from '@shared/contracts/routes/scheduledTasks.routes' export type MainKernelRouteRuntime = { configPresenter: IConfigPresenter @@ -270,6 +278,7 @@ export type MainKernelRouteRuntime = { startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter + scheduledTasks: ScheduledTasksService } export function createMainKernelRouteRuntime(deps: { @@ -293,6 +302,7 @@ export function createMainKernelRouteRuntime(deps: { startupWorkloadCoordinator: StartupWorkloadCoordinator pluginPresenter: PluginPresenter databaseSecurityPresenter: DatabaseSecurityPresenter + scheduledTasks: ScheduledTasksService }): MainKernelRouteRuntime { const scheduler = createNodeScheduler() const hotPathPorts = createPresenterHotPathPorts({ @@ -303,6 +313,47 @@ export function createMainKernelRouteRuntime(deps: { llmProviderPresenter: deps.llmProviderPresenter }) + const sessionService = new SessionService({ + sessionRepository: hotPathPorts.sessionRepository, + messageRepository: hotPathPorts.messageRepository, + scheduler + }) + const chatService = new ChatService({ + sessionRepository: hotPathPorts.sessionRepository, + messageRepository: hotPathPorts.messageRepository, + providerExecutionPort: hotPathPorts.providerExecutionPort, + providerCatalogPort: hotPathPorts.providerCatalogPort, + sessionPermissionPort: hotPathPorts.sessionPermissionPort, + scheduler + }) + + // Wire scheduled tasks -> sessions for the auto-send action. + deps.scheduledTasks.setSessionCreator({ + async createSessionForTask(input) { + const session = await sessionService.createSession( + { + agentId: input.agentId, + message: input.message, + providerId: input.providerId, + modelId: input.modelId, + ...(input.systemPrompt + ? { generationSettings: { systemPrompt: input.systemPrompt } } + : {}) + }, + { + webContentsId: deps.windowPresenter.mainWindow?.webContents?.id ?? -1, + windowId: deps.windowPresenter.mainWindow?.id ?? null + } + ) + if (!session?.id) { + return { sessionId: null } + } + + await chatService.sendMessage(session.id, input.message) + return { sessionId: session.id } + } + }) + return { configPresenter: deps.configPresenter, llmProviderPresenter: deps.llmProviderPresenter, @@ -332,19 +383,8 @@ export function createMainKernelRouteRuntime(deps: { }), listSettingsActivity: async () => [] } as unknown as ISQLitePresenter), - sessionService: new SessionService({ - sessionRepository: hotPathPorts.sessionRepository, - messageRepository: hotPathPorts.messageRepository, - scheduler - }), - chatService: new ChatService({ - sessionRepository: hotPathPorts.sessionRepository, - messageRepository: hotPathPorts.messageRepository, - providerExecutionPort: hotPathPorts.providerExecutionPort, - providerCatalogPort: hotPathPorts.providerCatalogPort, - sessionPermissionPort: hotPathPorts.sessionPermissionPort, - scheduler - }), + sessionService, + chatService, providerService: new ProviderService({ providerCatalogPort: hotPathPorts.providerCatalogPort, providerExecutionPort: hotPathPorts.providerExecutionPort, @@ -360,7 +400,8 @@ export function createMainKernelRouteRuntime(deps: { tabPresenter: deps.tabPresenter, startupWorkloadCoordinator: deps.startupWorkloadCoordinator, pluginPresenter: deps.pluginPresenter, - databaseSecurityPresenter: deps.databaseSecurityPresenter + databaseSecurityPresenter: deps.databaseSecurityPresenter, + scheduledTasks: deps.scheduledTasks } } @@ -1557,6 +1598,36 @@ export async function dispatchDeepchatRoute( return onboardingResetRoute.output.parse({ state }) } + case scheduledTasksListRoute.name: { + scheduledTasksListRoute.input.parse(rawInput) + const settings = runtime.scheduledTasks.list() + return scheduledTasksListRoute.output.parse({ settings }) + } + + case scheduledTasksUpsertRoute.name: { + const input = scheduledTasksUpsertRoute.input.parse(rawInput) + const { task, settings } = runtime.scheduledTasks.upsert(input) + return scheduledTasksUpsertRoute.output.parse({ task, settings }) + } + + case scheduledTasksDeleteRoute.name: { + const input = scheduledTasksDeleteRoute.input.parse(rawInput) + const settings = runtime.scheduledTasks.delete(input.id) + return scheduledTasksDeleteRoute.output.parse({ settings }) + } + + case scheduledTasksToggleRoute.name: { + const input = scheduledTasksToggleRoute.input.parse(rawInput) + const { task, settings } = runtime.scheduledTasks.toggle(input.id, input.enabled) + return scheduledTasksToggleRoute.output.parse({ task, settings }) + } + + case scheduledTasksFireNowRoute.name: { + const input = scheduledTasksFireNowRoute.input.parse(rawInput) + const { task, settings } = await runtime.scheduledTasks.fireNow(input.id) + return scheduledTasksFireNowRoute.output.parse({ task, settings }) + } + case startupGetBootstrapRoute.name: { startupGetBootstrapRoute.input.parse(rawInput) const coordinator = (runtime as Partial).startupWorkloadCoordinator diff --git a/src/renderer/api/ScheduledTasksClient.ts b/src/renderer/api/ScheduledTasksClient.ts new file mode 100644 index 000000000..e41a88b26 --- /dev/null +++ b/src/renderer/api/ScheduledTasksClient.ts @@ -0,0 +1,85 @@ +import type { DeepchatBridge } from '@shared/contracts/bridge' +import { + scheduledTasksDeleteRoute, + scheduledTasksFireNowRoute, + scheduledTasksListRoute, + scheduledTasksToggleRoute, + scheduledTasksUpsertRoute, + scheduledTasksSettingsSchema, + scheduledTaskSchema, + type scheduledTasksUpsertInputSchema +} from '@shared/contracts/routes/scheduledTasks.routes' +import type { z } from 'zod' +import { getDeepchatBridge } from './core' + +export type ScheduledTasksUpsertInput = z.input + +const parseSettingsResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) + } + const maybe = (result as { settings?: unknown }).settings + const parsed = scheduledTasksSettingsSchema.safeParse(maybe) + if (!parsed.success) { + throw new Error(`[ScheduledTasksClient] Invalid settings response from ${routeName}`) + } + return parsed.data +} + +const parseTaskResponse = (routeName: string, result: unknown) => { + if (typeof result !== 'object' || result === null) { + throw new Error(`[ScheduledTasksClient] Invalid response shape from ${routeName}`) + } + const maybeTask = (result as { task?: unknown }).task + const parsedTask = scheduledTaskSchema.safeParse(maybeTask) + if (!parsedTask.success) { + throw new Error(`[ScheduledTasksClient] Invalid task response from ${routeName}`) + } + return parsedTask.data +} + +export function createScheduledTasksClient(bridge: DeepchatBridge = getDeepchatBridge()) { + async function list() { + const result = await bridge.invoke(scheduledTasksListRoute.name, {}) + return parseSettingsResponse(scheduledTasksListRoute.name, result) + } + + async function upsert(input: ScheduledTasksUpsertInput) { + const result = await bridge.invoke(scheduledTasksUpsertRoute.name, input) + return { + task: parseTaskResponse(scheduledTasksUpsertRoute.name, result), + settings: parseSettingsResponse(scheduledTasksUpsertRoute.name, result) + } + } + + async function remove(id: string) { + const result = await bridge.invoke(scheduledTasksDeleteRoute.name, { id }) + return parseSettingsResponse(scheduledTasksDeleteRoute.name, result) + } + + async function toggle(id: string, enabled: boolean) { + const result = await bridge.invoke(scheduledTasksToggleRoute.name, { id, enabled }) + return { + task: parseTaskResponse(scheduledTasksToggleRoute.name, result), + settings: parseSettingsResponse(scheduledTasksToggleRoute.name, result) + } + } + + async function fireNow(id: string) { + const result = await bridge.invoke(scheduledTasksFireNowRoute.name, { id }) + return { + task: parseTaskResponse(scheduledTasksFireNowRoute.name, result), + settings: parseSettingsResponse(scheduledTasksFireNowRoute.name, result) + } + } + + return { + list, + upsert, + remove, + toggle, + fireNow + } +} + +export type ScheduledTasksClient = ReturnType diff --git a/src/renderer/settings/components/DisplaySettings.vue b/src/renderer/settings/components/DisplaySettings.vue index 9bcdd38fb..555cedc00 100644 --- a/src/renderer/settings/components/DisplaySettings.vue +++ b/src/renderer/settings/components/DisplaySettings.vue @@ -351,7 +351,15 @@ const languageOptions = [ { value: 'fa-IR', label: 'فارسی (ایران)' }, { value: 'pt-BR', label: 'Português (Brasil)' }, { value: 'da-DK', label: 'Dansk' }, - { value: 'he-IL', label: 'עברית (ישראל)' } + { value: 'he-IL', label: 'עברית (ישראל)' }, + { value: 'es-ES', label: 'Español (España)' }, + { value: 'de-DE', label: 'Deutsch (Deutschland)' }, + { value: 'tr-TR', label: 'Türkçe' }, + { value: 'id-ID', label: 'Bahasa Indonesia' }, + { value: 'ms-MY', label: 'Bahasa Melayu' }, + { value: 'it-IT', label: 'Italiano' }, + { value: 'pl-PL', label: 'Polski' }, + { value: 'vi-VN', label: 'Tiếng Việt' } ] watch(selectedLanguage, async (newValue) => { diff --git a/src/renderer/settings/components/ScheduledTasksSettings.vue b/src/renderer/settings/components/ScheduledTasksSettings.vue new file mode 100644 index 000000000..55b24f0f7 --- /dev/null +++ b/src/renderer/settings/components/ScheduledTasksSettings.vue @@ -0,0 +1,958 @@ +