From da2e999ce3caeab4531cf41aa82160ce8d9675db Mon Sep 17 00:00:00 2001 From: Acbox Date: Thu, 26 Mar 2026 21:00:21 +0800 Subject: [PATCH] feat: searchable timezone select & bot timezone priority - Add reusable TimezoneSelect component with search and UTC offset labels - Replace plain Select with searchable TimezoneSelect in profile settings, bot settings, and browser context settings - Move bot timezone setting from header dialog into bot settings tab - Resolve timezone with bot > user > system priority for all LLM-facing time formatting (user message header, system prompt, heartbeat, tools, memory extraction) - Format tool output timestamps (history, contacts) in resolved timezone --- .../src/components/timezone-select/index.vue | 63 ++++++++++ apps/web/src/i18n/locales/en.json | 4 +- apps/web/src/i18n/locales/zh.json | 4 +- .../pages/bots/components/bot-settings.vue | 65 +++++++++- apps/web/src/pages/bots/detail.vue | 112 ------------------ .../components/context-setting.vue | 40 ++----- .../settings/components/profile-section.vue | 32 +---- apps/web/src/utils/timezones.ts | 14 +++ internal/agent/agent.go | 1 + internal/agent/tools/contacts.go | 2 +- internal/agent/tools/history.go | 6 +- internal/agent/tools/types.go | 10 ++ internal/conversation/flow/resolver.go | 2 +- internal/conversation/flow/resolver_memory.go | 2 + .../conversation/flow/resolver_timezone.go | 91 ++++++++++---- internal/memory/adapters/builtin/formation.go | 3 +- internal/memory/adapters/types.go | 8 +- internal/memory/memllm/client.go | 6 +- 18 files changed, 259 insertions(+), 206 deletions(-) create mode 100644 apps/web/src/components/timezone-select/index.vue diff --git a/apps/web/src/components/timezone-select/index.vue b/apps/web/src/components/timezone-select/index.vue new file mode 100644 index 00000000..1888b5e0 --- /dev/null +++ b/apps/web/src/components/timezone-select/index.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 720d59b7..3d8603bf 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -34,7 +34,9 @@ "loadFailed": "Failed to load", "saveFailed": "Failed to save", "createdAt": "Created at", - "none": "None" + "none": "None", + "searchTimezone": "Search timezones…", + "noTimezoneFound": "No timezone found." }, "auth": { "welcome": "Welcome Back", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 5c9e1b7b..4e48b425 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -34,7 +34,9 @@ "loadFailed": "加载失败", "saveFailed": "保存失败", "createdAt": "创建时间", - "none": "无" + "none": "无", + "searchTimezone": "搜索时区…", + "noTimezoneFound": "未找到时区" }, "auth": { "welcome": "欢迎回来", diff --git a/apps/web/src/pages/bots/components/bot-settings.vue b/apps/web/src/pages/bots/components/bot-settings.vue index 60172d3f..69ac8acc 100644 --- a/apps/web/src/pages/bots/components/bot-settings.vue +++ b/apps/web/src/pages/bots/components/bot-settings.vue @@ -197,6 +197,21 @@ /> + +
+ + +

+ {{ $t('bots.timezoneInheritedHint') }} +

+
+ @@ -334,18 +349,20 @@ import { SelectTrigger, SelectValue, } from '@memohai/ui' -import { reactive, computed, watch } from 'vue' +import { reactive, computed, watch, ref } from 'vue' import { useRouter } from 'vue-router' import { toast } from 'vue-sonner' import { useI18n } from 'vue-i18n' import ConfirmPopover from '@/components/confirm-popover/index.vue' +import TimezoneSelect from '@/components/timezone-select/index.vue' +import { emptyTimezoneValue } from '@/utils/timezones' import ModelSelect from './model-select.vue' import SearchProviderSelect from './search-provider-select.vue' import MemoryProviderSelect from './memory-provider-select.vue' import TtsModelSelect from './tts-model-select.vue' import BrowserContextSelect from './browser-context-select.vue' import { useQuery, useMutation, useQueryCache } from '@pinia/colada' -import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk' +import { getBotsById, putBotsById, getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk' import type { SettingsSettings } from '@memohai/sdk' import type { Ref } from 'vue' import { resolveApiErrorMessage } from '@/utils/api-error' @@ -362,6 +379,15 @@ const botIdRef = computed(() => props.botId) as Ref // ---- Data ---- const queryCache = useQueryCache() +const { data: bot } = useQuery({ + key: () => ['bot', botIdRef.value], + query: async () => { + const { data } = await getBotsById({ path: { id: botIdRef.value }, throwOnError: true }) + return data + }, + enabled: () => !!botIdRef.value, +}) + const { data: settings } = useQuery({ key: () => ['bot-settings', botIdRef.value], query: async () => { @@ -477,6 +503,31 @@ const form = reactive({ reasoning_effort: 'medium', }) +const timezone = ref('') + +const timezoneModel = computed(() => timezone.value || emptyTimezoneValue) + +function onTimezoneChange(value: string) { + timezone.value = value === emptyTimezoneValue ? '' : value +} + +watch(bot, (val) => { + if (val) { + timezone.value = val.timezone || '' + } +}, { immediate: true }) + +const { mutateAsync: updateBot } = useMutation({ + mutation: async ({ id, ...body }: Record & { id: string }) => { + const { data } = await putBotsById({ path: { id }, body, throwOnError: true }) + return data + }, + onSettled: () => { + queryCache.invalidateQueries({ key: ['bots'] }) + queryCache.invalidateQueries({ key: ['bot'] }) + }, +}) + const selectedMemoryProvider = computed(() => memoryProviders.value.find((provider) => provider.id === form.memory_provider_id), ) @@ -590,10 +641,12 @@ watch(settings, (val) => { }, { immediate: true }) const hasChanges = computed(() => { + const timezoneChanged = timezone.value !== (bot.value?.timezone || '') if (!settings.value) return true const s = settings.value let changed = - form.chat_model_id !== (s.chat_model_id ?? '') + timezoneChanged + || form.chat_model_id !== (s.chat_model_id ?? '') || form.title_model_id !== (s.title_model_id ?? '') || form.search_provider_id !== (s.search_provider_id ?? '') || form.memory_provider_id !== (s.memory_provider_id ?? '') @@ -609,7 +662,11 @@ const hasChanges = computed(() => { async function handleSave() { try { - await updateSettings({ ...form }) + const promises: Promise[] = [updateSettings({ ...form })] + if (timezone.value !== (bot.value?.timezone || '')) { + promises.push(updateBot({ id: botIdRef.value, timezone: timezone.value })) + } + await Promise.all(promises) toast.success(t('bots.settings.saveSuccess')) } catch { return diff --git a/apps/web/src/pages/bots/detail.vue b/apps/web/src/pages/bots/detail.vue index 2bc577b0..d4fd0af3 100644 --- a/apps/web/src/pages/bots/detail.vue +++ b/apps/web/src/pages/bots/detail.vue @@ -93,22 +93,6 @@ {{ botTypeLabel }} -
- {{ $t('bots.timezone') }}: {{ bot.timezone || $t('bots.timezoneInherited') }} - -
@@ -209,67 +193,6 @@ - - - - - {{ $t('bots.editTimezone') }} - - {{ $t('bots.editTimezoneDescription') }} - - -
- -

- {{ $t('bots.timezoneInheritedHint') }} -

-
- - - - - - -
-
@@ -288,12 +211,6 @@ import { DialogHeader, DialogTitle, Input, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, Separator, Spinner, ScrollArea, @@ -338,7 +255,6 @@ import { useAvatarInitials } from '@/composables/useAvatarInitials' import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam' import { useBotStatusMeta } from '@/composables/useBotStatusMeta' import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue' -import { emptyTimezoneValue, timezones } from '@/utils/timezones' type BotCheck = BotsBotCheck type BotContainerInfo = HandlersGetContainerResponse type BotContainerSnapshot = HandlersListSnapshotsResponse extends { snapshots?: (infer T)[] } ? T : never @@ -428,12 +344,9 @@ watch(bot, (val) => { const activeTab = useSyncedQueryParam('tab', 'overview') const avatarDialogOpen = ref(false) const avatarUrlDraft = ref('') -const timezoneDialogOpen = ref(false) -const timezoneDraft = ref('') const avatarFallback = useAvatarInitials(() => bot.value?.display_name || botId.value || '') const isSavingBotName = computed(() => updateBotLoading.value) const avatarSaving = computed(() => updateBotLoading.value) -const timezoneSaving = computed(() => updateBotLoading.value) const canConfirmAvatar = computed(() => { if (!bot.value) return false const next = avatarUrlDraft.value.trim() @@ -446,10 +359,6 @@ const canConfirmBotName = computed(() => { if (!nextName) return false return nextName !== (bot.value.display_name || '').trim() }) -const canConfirmTimezone = computed(() => { - if (!bot.value) return false - return timezoneDraft.value.trim() !== (bot.value.timezone || '').trim() -}) const { hasIssue, isPending: botLifecyclePending, @@ -503,12 +412,6 @@ function handleEditAvatar() { avatarDialogOpen.value = true } -function handleEditTimezone() { - if (!bot.value || botLifecyclePending.value) return - timezoneDraft.value = bot.value.timezone || '' - timezoneDialogOpen.value = true -} - async function handleConfirmAvatar() { if (!bot.value || !canConfirmAvatar.value || avatarSaving.value) return const nextUrl = avatarUrlDraft.value.trim() @@ -524,21 +427,6 @@ async function handleConfirmAvatar() { } } -async function handleConfirmTimezone() { - if (!bot.value || !canConfirmTimezone.value || timezoneSaving.value) return - const nextTimezone = timezoneDraft.value.trim() - try { - await updateBot({ - id: bot.value.id as string, - timezone: nextTimezone, - }) - timezoneDialogOpen.value = false - toast.success(t('bots.timezoneUpdateSuccess')) - } catch (error) { - toast.error(resolveErrorMessage(error, t('bots.timezoneUpdateFailed'))) - } -} - function handleStartEditBotName() { if (!bot.value) return isEditingBotName.value = true diff --git a/apps/web/src/pages/browser-contexts/components/context-setting.vue b/apps/web/src/pages/browser-contexts/components/context-setting.vue index 13dd68fe..8a2fb5b9 100644 --- a/apps/web/src/pages/browser-contexts/components/context-setting.vue +++ b/apps/web/src/pages/browser-contexts/components/context-setting.vue @@ -149,34 +149,19 @@ - + @@ -249,12 +234,6 @@ import { Label, Separator, Button, - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, Switch, } from '@memohai/ui' import { toTypedSchema } from '@vee-validate/zod' @@ -270,7 +249,8 @@ import { toast } from 'vue-sonner' import { useDialogMutation } from '@/composables/useDialogMutation' import ConfirmPopover from '@/components/confirm-popover/index.vue' import { resolveApiErrorMessage } from '@/utils/api-error' -import { emptyTimezoneValue, timezones } from '@/utils/timezones' +import { emptyTimezoneValue } from '@/utils/timezones' +import TimezoneSelect from '@/components/timezone-select/index.vue' const { t } = useI18n() const { run } = useDialogMutation() diff --git a/apps/web/src/pages/settings/components/profile-section.vue b/apps/web/src/pages/settings/components/profile-section.vue index 0d8d0abf..ed7b41ee 100644 --- a/apps/web/src/pages/settings/components/profile-section.vue +++ b/apps/web/src/pages/settings/components/profile-section.vue @@ -48,29 +48,11 @@
- + />