mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<SearchableSelectPopover
|
||||
v-model="selected"
|
||||
:options="options"
|
||||
:placeholder="placeholder || ''"
|
||||
:aria-label="placeholder || 'Select timezone'"
|
||||
:search-placeholder="$t('common.searchTimezone')"
|
||||
search-aria-label="Search timezones"
|
||||
:empty-text="$t('common.noTimezoneFound')"
|
||||
:show-group-headers="false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue'
|
||||
import type { SearchableSelectOption } from '@/components/searchable-select-popover/index.vue'
|
||||
import { timezones, emptyTimezoneValue, getUtcOffsetLabel } from '@/utils/timezones'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
placeholder?: string
|
||||
allowEmpty?: boolean
|
||||
emptyLabel?: string
|
||||
}>(), {
|
||||
placeholder: '',
|
||||
allowEmpty: false,
|
||||
emptyLabel: '',
|
||||
})
|
||||
|
||||
const selected = defineModel<string>({ default: '' })
|
||||
|
||||
const offsetMap = computed(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const tz of timezones) {
|
||||
map.set(tz, getUtcOffsetLabel(tz))
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const options = computed<SearchableSelectOption[]>(() => {
|
||||
const items: SearchableSelectOption[] = []
|
||||
if (props.allowEmpty) {
|
||||
items.push({
|
||||
value: emptyTimezoneValue,
|
||||
label: props.emptyLabel || t('bots.timezoneInherited'),
|
||||
})
|
||||
}
|
||||
for (const tz of timezones) {
|
||||
const parts = tz.split('/')
|
||||
const offset = offsetMap.value.get(tz) ?? ''
|
||||
items.push({
|
||||
value: tz,
|
||||
label: tz,
|
||||
description: offset,
|
||||
keywords: [...parts, offset],
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
@@ -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",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"loadFailed": "加载失败",
|
||||
"saveFailed": "保存失败",
|
||||
"createdAt": "创建时间",
|
||||
"none": "无"
|
||||
"none": "无",
|
||||
"searchTimezone": "搜索时区…",
|
||||
"noTimezoneFound": "未找到时区"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "欢迎回来",
|
||||
|
||||
@@ -197,6 +197,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Timezone -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.timezone') }}</Label>
|
||||
<TimezoneSelect
|
||||
:model-value="timezoneModel"
|
||||
:placeholder="$t('bots.timezonePlaceholder')"
|
||||
allow-empty
|
||||
:empty-label="$t('bots.timezoneInherited')"
|
||||
@update:model-value="onTimezoneChange"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.timezoneInheritedHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Max Context Load Time -->
|
||||
@@ -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<string>
|
||||
// ---- 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<string, unknown> & { 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<unknown>[] = [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
|
||||
|
||||
@@ -93,22 +93,6 @@
|
||||
</Badge>
|
||||
<span v-if="bot?.type">{{ botTypeLabel }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="bot"
|
||||
class="mt-1 flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>{{ $t('bots.timezone') }}: {{ bot.timezone || $t('bots.timezoneInherited') }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2"
|
||||
:disabled="botLifecyclePending"
|
||||
@click="handleEditTimezone"
|
||||
>
|
||||
{{ $t('common.edit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -209,67 +193,6 @@
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="timezoneDialogOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t('bots.editTimezone') }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ $t('bots.editTimezoneDescription') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<Select
|
||||
:model-value="timezoneDraft || emptyTimezoneValue"
|
||||
@update:model-value="(value) => timezoneDraft = value === emptyTimezoneValue ? '' : String(value)"
|
||||
>
|
||||
<SelectTrigger
|
||||
class="w-full"
|
||||
:disabled="timezoneSaving"
|
||||
>
|
||||
<SelectValue :placeholder="$t('bots.timezonePlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem :value="emptyTimezoneValue">
|
||||
{{ $t('bots.timezoneInherited') }}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="timezoneOption in timezones"
|
||||
:key="timezoneOption"
|
||||
:value="timezoneOption"
|
||||
>
|
||||
{{ timezoneOption }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('bots.timezoneInheritedHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter class="mt-6">
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="timezoneSaving"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
:disabled="timezoneSaving || !canConfirmTimezone"
|
||||
@click="handleConfirmTimezone"
|
||||
>
|
||||
<Spinner
|
||||
v-if="timezoneSaving"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('common.confirm') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,34 +149,19 @@
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
v-slot="{ value, handleChange }"
|
||||
name="timezoneId"
|
||||
>
|
||||
<FormItem>
|
||||
<Label>{{ $t('browserContext.timezoneId') }}</Label>
|
||||
<FormControl>
|
||||
<Select
|
||||
:model-value="componentField.modelValue || emptyTimezoneValue"
|
||||
@update:model-value="(value) => componentField['onUpdate:modelValue'](value === emptyTimezoneValue ? '' : value)"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('browserContext.timezonePlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem :value="emptyTimezoneValue">
|
||||
{{ $t('common.optional') }}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="timezoneOption in timezones"
|
||||
:key="timezoneOption"
|
||||
:value="timezoneOption"
|
||||
>
|
||||
{{ timezoneOption }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TimezoneSelect
|
||||
:model-value="value || emptyTimezoneValue"
|
||||
:placeholder="$t('browserContext.timezonePlaceholder')"
|
||||
allow-empty
|
||||
:empty-label="$t('common.optional')"
|
||||
@update:model-value="(val) => handleChange(val === emptyTimezoneValue ? '' : val)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
@@ -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()
|
||||
|
||||
@@ -48,29 +48,11 @@
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="settings-timezone">{{ $t('settings.timezone') }}</Label>
|
||||
<Select
|
||||
<TimezoneSelect
|
||||
:model-value="timezone"
|
||||
:placeholder="$t('settings.timezonePlaceholder')"
|
||||
@update:model-value="onTimezoneChange"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="settings-timezone"
|
||||
class="w-full"
|
||||
:aria-label="$t('settings.timezone')"
|
||||
>
|
||||
<SelectValue :placeholder="$t('settings.timezonePlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="timezoneOption in timezones"
|
||||
:key="timezoneOption"
|
||||
:value="timezoneOption"
|
||||
>
|
||||
{{ timezoneOption }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
@@ -90,16 +72,10 @@ import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Separator,
|
||||
Spinner,
|
||||
} from '@memohai/ui'
|
||||
import { timezones } from '@/utils/timezones'
|
||||
import TimezoneSelect from '@/components/timezone-select/index.vue'
|
||||
|
||||
defineProps<{
|
||||
displayUserId: string
|
||||
|
||||
@@ -5,3 +5,17 @@ export const timezones = typeof Intl.supportedValuesOf === 'function'
|
||||
: fallbackTimezones
|
||||
|
||||
export const emptyTimezoneValue = '__empty_timezone__'
|
||||
|
||||
export function getUtcOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const now = new Date()
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(now)
|
||||
const offsetPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return offsetPart?.value ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user