diff --git a/frontend/src/api/conversation.ts b/frontend/src/api/conversation.ts index 41acf20..1252dca 100644 --- a/frontend/src/api/conversation.ts +++ b/frontend/src/api/conversation.ts @@ -101,7 +101,8 @@ export const conversationApi = { handlers: ChatStreamHandlers = {}, ) { const token = localStorage.getItem('access_token') - const response = await fetch(`${import.meta.env.VITE_API_URL}/api/conversations/chat/stream`, { + const baseURL = import.meta.env.DEV ? '' : import.meta.env.VITE_API_URL + const response = await fetch(`${baseURL}/api/conversations/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/pages/chat/composables/useClientTime.ts b/frontend/src/pages/chat/composables/useClientTime.ts index fcdf371..9e5640d 100644 --- a/frontend/src/pages/chat/composables/useClientTime.ts +++ b/frontend/src/pages/chat/composables/useClientTime.ts @@ -13,17 +13,53 @@ export function useClientTime() { const weatherSummary = ref('Weather unavailable') const weatherCode = ref(null) let clientTimeTimer: ReturnType | null = null + const WEATHER_CACHE_KEY = 'jarvis:clientWeather' - // Load location from backend config - async function loadLocation() { + function loadWeatherCache() { + if (typeof window === 'undefined') return + try { + const raw = window.localStorage.getItem(WEATHER_CACHE_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as { city?: string; weather_summary?: string; weather_code?: number | null; cached_at?: number } + if (typeof parsed.city === 'string' && parsed.city.trim()) city.value = parsed.city + if (typeof parsed.weather_summary === 'string' && parsed.weather_summary.trim()) weatherSummary.value = parsed.weather_summary + if (typeof parsed.weather_code === 'number') weatherCode.value = parsed.weather_code + } catch { + // ignore cache parse errors + } + } + + function saveWeatherCache(payload: { city: string; weather_summary: string; weather_code: number | null }) { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(WEATHER_CACHE_KEY, JSON.stringify({ ...payload, cached_at: Date.now() })) + } catch { + // ignore storage errors + } + } + + // Read cache synchronously during setup to avoid initial render flicker (icon showing as X/na). + loadWeatherCache() + + async function loadSystemConfig() { try { const response = await fetch('/api/system/config') - if (response.ok) { - const config = await response.json() - city.value = config.location || 'Location' - } + if (!response.ok) throw new Error('system config request failed') + const config = await response.json() + city.value = typeof config.location === 'string' && config.location.trim() ? config.location : 'Location' + weatherCode.value = typeof config.weather_code === 'number' ? config.weather_code : null + weatherSummary.value = typeof config.weather_summary === 'string' && config.weather_summary.trim() + ? config.weather_summary + : 'Weather unavailable' + saveWeatherCache({ city: city.value, weather_summary: weatherSummary.value, weather_code: weatherCode.value }) } catch { - city.value = import.meta.env.VITE_LOCATION || 'Location' + // If we already have cached weather on screen, keep it to avoid UI flicker (icon showing as "X"/na). + const hasExistingWeather = weatherCode.value !== null || (weatherSummary.value && weatherSummary.value !== 'Weather unavailable') + if (!hasExistingWeather) { + city.value = 'Location' + weatherCode.value = null + weatherSummary.value = 'Weather unavailable' + } } } @@ -40,6 +76,27 @@ export function useClientTime() { } function weatherCodeLabel(code: number | null | undefined) { + // Backend may return wttr.in condition codes (e.g. 113/116/119...), normalize to user-friendly labels. + if (typeof code === 'number' && code > 99) { + if (code === 113) return 'Clear' + if (code === 116) return 'Partly Cloudy' + if (code === 119 || code === 122) return 'Overcast' + if (code === 143) return 'Fog' + if ([200, 386, 389].includes(code)) return 'Thunderstorm' + if ( + [ + 176, 263, 266, 281, 293, 296, 299, 302, 305, 308, + 311, 314, 317, 350, 353, 356, 359, 362, + ].includes(code) + ) return 'Rain' + if ( + [ + 179, 182, 185, 227, 230, 323, 326, 329, 332, 335, + 338, 368, 371, 374, 377, 392, 395, + ].includes(code) + ) return 'Snow' + return 'Weather' + } if (code === 0) return 'Clear' if (code === 1 || code === 2) return 'Partly Cloudy' if (code === 3) return 'Overcast' @@ -48,11 +105,34 @@ export function useClientTime() { if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return 'Rain' if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'Snow' if ([95, 96, 99].includes(code ?? -1)) return 'Thunderstorm' - return 'Weather' + return 'Weather unavailable' } const weatherIcon = computed(() => { const code = weatherCode.value + if (code === null || code === undefined) return '' + // Support wttr.in weather codes (commonly 113/116/119/122/143/...). + if (typeof code === 'number' && code > 99) { + if (code === 113) return 'wi-day-sunny' + if (code === 116) return 'wi-day-cloudy' + if (code === 119) return 'wi-cloudy' + if (code === 122) return 'wi-cloudy' + if (code === 143) return 'wi-fog' + if ([200, 386, 389].includes(code)) return 'wi-thunderstorm' + if ( + [ + 176, 263, 266, 281, 293, 296, 299, 302, 305, 308, + 311, 314, 317, 350, 353, 356, 359, 362, + ].includes(code) + ) return 'wi-rain' + if ( + [ + 179, 182, 185, 227, 230, 323, 326, 329, 332, 335, + 338, 368, 371, 374, 377, 392, 395, + ].includes(code) + ) return 'wi-snow' + return '' + } if (code === 0) return 'wi-day-sunny' if (code === 1) return 'wi-day-cloudy' if (code === 2) return 'wi-day-cloudy-gusts' @@ -65,68 +145,13 @@ export function useClientTime() { if ([66, 67, 81, 82].includes(code ?? -1)) return 'wi-rain' if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return 'wi-snow' if ([95, 96, 99].includes(code ?? -1)) return 'wi-thunderstorm' - return 'wi-day-sunny' + return '' }) - // Fallback: directly load weather with default location (Beijing) - async function loadWeatherByIP() { - console.log('[Weather] Using default location (Beijing) for weather') - const defaultLat = 39.9042 - const defaultLon = 116.4074 - await loadWeather(defaultLat, defaultLon) - } - - async function loadWeather(latitude: number, longitude: number) { - console.log('[Weather] Loading weather for:', latitude, longitude) - try { - // Fetch weather data from Open-Meteo - const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=temperature_2m,weather_code&timezone=auto` - console.log('[Weather] Fetching:', url) - const weatherResp = await fetch(url) - console.log('[Weather] Response status:', weatherResp.status) - if (!weatherResp.ok) throw new Error('weather request failed') - const weatherData = await weatherResp.json() - console.log('[Weather] Response data:', JSON.stringify(weatherData)) - const current = weatherData.current ?? {} - weatherCode.value = typeof current.weather_code === 'number' ? current.weather_code : null - const temp = typeof current.temperature_2m === 'number' ? `${Math.round(current.temperature_2m)}°C` : '--' - weatherSummary.value = `${weatherCodeLabel(current.weather_code)} ${temp}` - console.log('[Weather] Updated summary:', weatherSummary.value) - - // Only fetch city name if not already set by config - if (city.value === 'Location') { - try { - const geoResp = await fetch( - `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=10`, - ) - if (geoResp.ok) { - const geoData = await geoResp.json() - city.value = geoData.address?.city ?? geoData.address?.town ?? geoData.address?.county ?? geoData.display_name?.split(',')[0] ?? 'Location' - } - } catch { - city.value = 'Location' - } - } - } catch (err) { - console.warn('[Weather] API failed (keeping previous value):', err) - // Don't overwrite existing value on error - keep the default we set earlier - } - } - - onMounted(() => { + onMounted(async () => { updateClientTime() clientTimeTimer = setInterval(updateClientTime, 1000) - void loadLocation() - - // Set default weather immediately - weatherCode.value = 0 - weatherSummary.value = 'Clear 25°C' - city.value = 'Beijing' - - // Try to get real weather from Open-Meteo - const defaultLat = 39.9042 - const defaultLon = 116.4074 - loadWeather(defaultLat, defaultLon) + await loadSystemConfig() }) onUnmounted(() => { @@ -134,7 +159,15 @@ export function useClientTime() { }) return { - clientTime, city, weatherSummary, weatherCode, weatherIcon, - updateClientTime, formatClientDate, formatClientClock, weatherCodeLabel, loadWeather + clientTime, + city, + weatherSummary, + weatherCode, + weatherIcon, + updateClientTime, + formatClientDate, + formatClientClock, + weatherCodeLabel, + loadSystemConfig, } } diff --git a/frontend/src/pages/chat/composables/useSidebarPlan.ts b/frontend/src/pages/chat/composables/useSidebarPlan.ts index 2499587..0610072 100644 --- a/frontend/src/pages/chat/composables/useSidebarPlan.ts +++ b/frontend/src/pages/chat/composables/useSidebarPlan.ts @@ -1,4 +1,4 @@ -import { computed, onMounted, ref, watch, toRef } from 'vue' +import { computed, onMounted, ref, watch, type Ref } from 'vue' import { CornerDownLeft, Database, Sparkles, Sun, ListTodo } from 'lucide-vue-next' import { scheduleCenterApi, type ScheduleCenterDateResponse, type ScheduleCenterDaySummary } from '@/api/scheduleCenter' import type { Conversation } from '@/api/conversation' @@ -18,20 +18,24 @@ export const sidebarCollapsedModules = [ { id: 'review', label: '复盘', icon: CornerDownLeft }, ] -function formatDateKey(date: Date) { - const year = date.getUTCFullYear() - const month = String(date.getUTCMonth() + 1).padStart(2, '0') - const day = String(date.getUTCDate()).padStart(2, '0') +export function formatDateKey(date: Date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function formatMonthKey(date: Date) { - const year = date.getUTCFullYear() - const month = String(date.getUTCMonth() + 1).padStart(2, '0') + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') return `${year}-${month}` } -export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn: () => void, conversationsRef: Conversation[] = []) { +export function useSidebarPlan( + clientTimeRef: { value: Date }, + loadDailyDigestFn: () => void, + conversationsRef: Ref | Conversation[] = [], +) { const todayPlanDetail = ref(null) const monthPlanDays = ref([]) const selectedDate = ref(null) @@ -41,9 +45,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn const map = new Map() const conversations = Array.isArray(conversationsRef) ? conversationsRef : (conversationsRef.value ?? []) conversations.forEach((conv) => { - const date = new Date(conv.updated_at) - const dateKey = formatDateKey(date) - map.set(dateKey, true) + map.set(formatDateKey(new Date(conv.updated_at)), true) }) return map }) @@ -52,22 +54,22 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn const monthPlanSummaryMap = computed(() => new Map(monthPlanDays.value.map((item) => [item.date, item]))) const calendarCells = computed(() => { - const year = clientTimeRef.value.getUTCFullYear() - const month = clientTimeRef.value.getUTCMonth() - const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate() - const firstDayOffset = (new Date(Date.UTC(year, month, 1)).getUTCDay() + 6) % 7 - const today = clientTimeRef.value.getUTCDate() + const year = clientTimeRef.value.getFullYear() + const month = clientTimeRef.value.getMonth() + const daysInMonth = new Date(year, month + 1, 0).getDate() + const firstDayOffset = (new Date(year, month, 1).getDay() + 6) % 7 + const todayKey = todayDateKey.value const cells: Array<{ key: string; value: number | null; active: boolean; busy: boolean; selected: boolean; hasConversation: boolean }> = [] for (let index = 0; index < firstDayOffset; index += 1) { cells.push({ key: `blank-start-${index}`, value: null, active: false, busy: false, selected: false, hasConversation: false }) } for (let day = 1; day <= daysInMonth; day += 1) { - const monthDate = new Date(Date.UTC(year, month, day)) + const monthDate = new Date(year, month, day) const dateKey = formatDateKey(monthDate) const summary = monthPlanSummaryMap.value.get(dateKey) const busy = Boolean(summary && (summary.todo_total + summary.task_due_total + summary.goal_total + summary.reminder_total) > 0) const hasConv = conversationDateMap.value.get(dateKey) || false - cells.push({ key: dateKey, value: day, active: day === today, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv }) + cells.push({ key: dateKey, value: day, active: dateKey === todayKey, busy, selected: dateKey === selectedDate.value, hasConversation: hasConv }) } while (cells.length % 7 !== 0) { cells.push({ key: `blank-end-${cells.length}`, value: null, active: false, busy: false, selected: false, hasConversation: false }) @@ -75,8 +77,8 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn return cells }) - const calendarYear = computed(() => clientTimeRef.value.getUTCFullYear()) - const calendarMonth = computed(() => clientTimeRef.value.getUTCMonth() + 1) + const calendarYear = computed(() => clientTimeRef.value.getFullYear()) + const calendarMonth = computed(() => clientTimeRef.value.getMonth() + 1) const todayPlanCounters = computed(() => { const detail = todayPlanDetail.value diff --git a/frontend/src/pages/chat/index.vue b/frontend/src/pages/chat/index.vue index 95f2b89..249b979 100644 --- a/frontend/src/pages/chat/index.vue +++ b/frontend/src/pages/chat/index.vue @@ -1,5 +1,5 @@