mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +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 ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +405,7 @@ func (a *Agent) assembleTools(ctx context.Context, cfg RunConfig) ([]sdk.Tool, e
|
||||
SupportsImageInput: cfg.SupportsImageInput,
|
||||
IsSubagent: cfg.Identity.IsSubagent,
|
||||
Skills: skillsMap,
|
||||
TimezoneLocation: cfg.Identity.TimezoneLocation,
|
||||
}
|
||||
|
||||
var allTools []sdk.Tool
|
||||
|
||||
@@ -67,7 +67,7 @@ func (p *ContactsProvider) Tools(_ context.Context, session SessionContext) ([]s
|
||||
"conversation_type": r.ConversationType,
|
||||
"target": r.ReplyTarget,
|
||||
"conversation_id": r.ConversationID,
|
||||
"last_active": r.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"last_active": sess.FormatTime(r.UpdatedAt),
|
||||
}
|
||||
if len(r.Metadata) > 0 {
|
||||
if v, ok := r.Metadata["conversation_name"].(string); ok && v != "" {
|
||||
|
||||
@@ -166,8 +166,8 @@ func (p *HistoryProvider) execListSessions(ctx context.Context, sess SessionCont
|
||||
"platform": s.ChannelType,
|
||||
"route_id": s.RouteID,
|
||||
"conversation_type": s.RouteConversationType,
|
||||
"last_active": s.UpdatedAt.Format(time.RFC3339),
|
||||
"created_at": s.CreatedAt.Format(time.RFC3339),
|
||||
"last_active": sess.FormatTime(s.UpdatedAt),
|
||||
"created_at": sess.FormatTime(s.CreatedAt),
|
||||
}
|
||||
|
||||
if m := s.RouteMetadata; len(m) > 0 {
|
||||
@@ -261,7 +261,7 @@ func (p *HistoryProvider) execSearchMessages(ctx context.Context, sess SessionCo
|
||||
"session_id": row.SessionID.String(),
|
||||
"role": row.Role,
|
||||
"text": text,
|
||||
"created_at": row.CreatedAt.Time.Format(time.RFC3339),
|
||||
"created_at": sess.FormatTime(row.CreatedAt.Time),
|
||||
}
|
||||
if dbpkg.TextToString(row.Platform) != "" {
|
||||
entry["platform"] = dbpkg.TextToString(row.Platform)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "github.com/memohai/twilight-ai/sdk"
|
||||
)
|
||||
@@ -28,6 +29,15 @@ type SessionContext struct {
|
||||
SupportsImageInput bool
|
||||
IsSubagent bool
|
||||
Skills map[string]SkillDetail
|
||||
TimezoneLocation *time.Location
|
||||
}
|
||||
|
||||
// FormatTime formats a time.Time using the session timezone (falls back to UTC).
|
||||
func (s SessionContext) FormatTime(t time.Time) string {
|
||||
if s.TimezoneLocation != nil {
|
||||
t = t.In(s.TimezoneLocation)
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// ToolProvider supplies a set of tools for the agent.
|
||||
|
||||
@@ -158,7 +158,7 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r
|
||||
return resolvedContext{}, err
|
||||
}
|
||||
loopDetectionEnabled := r.loadBotLoopDetectionEnabled(ctx, req.BotID)
|
||||
userTimezoneName, userClockLocation := r.resolveUserTimezone(ctx, req.UserID)
|
||||
userTimezoneName, userClockLocation := r.resolveTimezone(ctx, req.BotID, req.UserID)
|
||||
|
||||
var chatSettings conversation.Settings
|
||||
if r.conversationSvc != nil {
|
||||
|
||||
@@ -69,12 +69,14 @@ func (r *Resolver) storeMemory(ctx context.Context, req conversation.ChatRequest
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
_, tzLoc := r.resolveTimezone(ctx, req.BotID, req.UserID)
|
||||
if err := p.OnAfterChat(ctx, memprovider.AfterChatRequest{
|
||||
BotID: botID,
|
||||
Messages: memMsgs,
|
||||
UserID: strings.TrimSpace(req.UserID),
|
||||
ChannelIdentityID: strings.TrimSpace(req.SourceChannelIdentityID),
|
||||
DisplayName: r.resolveDisplayName(ctx, req),
|
||||
TimezoneLocation: tzLoc,
|
||||
}); err != nil {
|
||||
r.logger.Warn("memory provider OnAfterChat failed", slog.String("bot_id", botID), slog.Any("error", err))
|
||||
}
|
||||
|
||||
@@ -6,39 +6,90 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/memohai/memoh/internal/db"
|
||||
"github.com/memohai/memoh/internal/timezone"
|
||||
)
|
||||
|
||||
func (r *Resolver) resolveUserTimezone(ctx context.Context, userID string) (string, *time.Location) {
|
||||
fallbackLocation := r.clockLocation
|
||||
fallbackName := timezone.DefaultName
|
||||
if fallbackLocation != nil {
|
||||
fallbackName = fallbackLocation.String()
|
||||
} else {
|
||||
fallbackLocation = timezone.MustResolve(fallbackName)
|
||||
// resolveTimezone resolves the effective timezone for a request.
|
||||
// Priority: bot timezone > user timezone > system default.
|
||||
func (r *Resolver) resolveTimezone(ctx context.Context, botID, userID string) (string, *time.Location) {
|
||||
fallbackName, fallbackLocation := r.systemTimezoneDefaults()
|
||||
|
||||
// 1. Try bot timezone first.
|
||||
if name, loc, ok := r.loadBotTimezone(ctx, botID); ok {
|
||||
return name, loc
|
||||
}
|
||||
|
||||
if r.accountService == nil {
|
||||
return fallbackName, fallbackLocation
|
||||
// 2. Fall back to user timezone.
|
||||
if name, loc, ok := r.loadUserTimezone(ctx, userID); ok {
|
||||
return name, loc
|
||||
}
|
||||
account, err := r.accountService.Get(ctx, strings.TrimSpace(userID))
|
||||
|
||||
return fallbackName, fallbackLocation
|
||||
}
|
||||
|
||||
func (r *Resolver) systemTimezoneDefaults() (string, *time.Location) {
|
||||
if r.clockLocation != nil {
|
||||
return r.clockLocation.String(), r.clockLocation
|
||||
}
|
||||
return timezone.DefaultName, timezone.MustResolve(timezone.DefaultName)
|
||||
}
|
||||
|
||||
func (r *Resolver) loadBotTimezone(ctx context.Context, botID string) (string, *time.Location, bool) {
|
||||
if r.queries == nil || strings.TrimSpace(botID) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
botUUID, err := db.ParseUUID(botID)
|
||||
if err != nil {
|
||||
return fallbackName, fallbackLocation
|
||||
return "", nil, false
|
||||
}
|
||||
if strings.TrimSpace(account.Timezone) == "" {
|
||||
return fallbackName, fallbackLocation
|
||||
row, err := r.queries.GetBotByID(ctx, botUUID)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
loc, name, err := timezone.Resolve(account.Timezone)
|
||||
tz := ""
|
||||
if row.Timezone.Valid {
|
||||
tz = strings.TrimSpace(row.Timezone.String)
|
||||
}
|
||||
if tz == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
loc, name, err := timezone.Resolve(tz)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn(
|
||||
"resolve user timezone failed",
|
||||
slog.String("user_id", userID),
|
||||
slog.String("timezone", account.Timezone),
|
||||
r.logger.Warn("resolve bot timezone failed",
|
||||
slog.String("bot_id", botID),
|
||||
slog.String("timezone", tz),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
return fallbackName, fallbackLocation
|
||||
return "", nil, false
|
||||
}
|
||||
return name, loc
|
||||
return name, loc, true
|
||||
}
|
||||
|
||||
func (r *Resolver) loadUserTimezone(ctx context.Context, userID string) (string, *time.Location, bool) {
|
||||
if r.accountService == nil || strings.TrimSpace(userID) == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
account, err := r.accountService.Get(ctx, strings.TrimSpace(userID))
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
tz := strings.TrimSpace(account.Timezone)
|
||||
if tz == "" {
|
||||
return "", nil, false
|
||||
}
|
||||
loc, name, err := timezone.Resolve(tz)
|
||||
if err != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("resolve user timezone failed",
|
||||
slog.String("user_id", userID),
|
||||
slog.String("timezone", tz),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
return name, loc, true
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ func runFormation(ctx context.Context, logger *slog.Logger, llm adapters.LLM, ru
|
||||
result := formationResult{}
|
||||
|
||||
extracted, err := llm.Extract(ctx, adapters.ExtractRequest{
|
||||
Messages: req.Messages,
|
||||
Messages: req.Messages,
|
||||
TimezoneLocation: req.TimezoneLocation,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("memory formation: extract failed", slog.String("bot_id", botID), slog.Any("error", err))
|
||||
|
||||
@@ -24,6 +24,7 @@ type AfterChatRequest struct {
|
||||
UserID string
|
||||
ChannelIdentityID string
|
||||
DisplayName string
|
||||
TimezoneLocation *time.Location
|
||||
}
|
||||
|
||||
// LLM is the interface for LLM operations needed by memory service.
|
||||
@@ -148,9 +149,10 @@ type DeleteResponse struct {
|
||||
}
|
||||
|
||||
type ExtractRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
Filters map[string]any `json:"filters,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Messages []Message `json:"messages"`
|
||||
Filters map[string]any `json:"filters,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
TimezoneLocation *time.Location `json:"-"`
|
||||
}
|
||||
|
||||
type ExtractResponse struct {
|
||||
|
||||
@@ -79,7 +79,11 @@ func (c *Client) Extract(ctx context.Context, req adapters.ExtractRequest) (adap
|
||||
return adapters.ExtractResponse{}, nil
|
||||
}
|
||||
|
||||
systemPrompt := strings.ReplaceAll(agent.MemoryExtractPrompt, "{{today}}", time.Now().UTC().Format("2006-01-02"))
|
||||
now := time.Now()
|
||||
if req.TimezoneLocation != nil {
|
||||
now = now.In(req.TimezoneLocation)
|
||||
}
|
||||
systemPrompt := strings.ReplaceAll(agent.MemoryExtractPrompt, "{{today}}", now.Format("2006-01-02"))
|
||||
|
||||
result, err := sdk.GenerateTextResult(ctx,
|
||||
sdk.WithModel(c.model()),
|
||||
|
||||
Reference in New Issue
Block a user