feat(frontend): update chat composables and vite config
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -13,17 +13,53 @@ export function useClientTime() {
|
||||
const weatherSummary = ref('Weather unavailable')
|
||||
const weatherCode = ref<number | null>(null)
|
||||
let clientTimeTimer: ReturnType<typeof setInterval> | 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]> | Conversation[] = [],
|
||||
) {
|
||||
const todayPlanDetail = ref<ScheduleCenterDateResponse | null>(null)
|
||||
const monthPlanDays = ref<ScheduleCenterDaySummary[]>([])
|
||||
const selectedDate = ref<string | null>(null)
|
||||
@@ -41,9 +45,7 @@ export function useSidebarPlan(clientTimeRef: { value: Date }, loadDailyDigestFn
|
||||
const map = new Map<string, boolean>()
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, toRef } from 'vue'
|
||||
import {
|
||||
ChevronRight,
|
||||
Send,
|
||||
@@ -25,7 +25,7 @@ import { useChatView } from '@/pages/chat/composables/useChatView'
|
||||
import { useKnowledgeView } from '@/pages/knowledge/composables/useKnowledgeView'
|
||||
import { useDailyDigest } from '@/pages/chat/composables/useDailyDigest'
|
||||
import { useClientTime, formatNetworkRate } from '@/pages/chat/composables/useClientTime'
|
||||
import { useSidebarPlan } from '@/pages/chat/composables/useSidebarPlan'
|
||||
import { useSidebarPlan, formatDateKey } from '@/pages/chat/composables/useSidebarPlan'
|
||||
|
||||
// --- Chat view (core messaging logic) ---
|
||||
const {
|
||||
@@ -79,7 +79,7 @@ const {
|
||||
sidebarFocusItems, sidebarReviewAchievements, sidebarReviewReflections,
|
||||
sidebarFeedItems, topbarFeedItems, sidebarCollapsedModules,
|
||||
selectedDate, selectCalendarDate
|
||||
} = useSidebarPlan(clientTime, loadDailyDigest, store.conversations)
|
||||
} = useSidebarPlan(clientTime, loadDailyDigest, toRef(store, 'conversations'))
|
||||
|
||||
// --- Local UI state ---
|
||||
const sidebarCollapsed = ref(false)
|
||||
@@ -118,27 +118,16 @@ function handleOpenPreview(doc: any) { previewDoc.value = doc }
|
||||
function handleCalendarDateSelect(dateKey: string) {
|
||||
selectCalendarDate(dateKey)
|
||||
|
||||
// Reload conversations to get latest data
|
||||
loadConversations().then(() => {
|
||||
// Find conversation that matches the selected date (by updated_at)
|
||||
// Use UTC to avoid timezone issues
|
||||
const [year, month, day] = dateKey.split('-').map(Number)
|
||||
const targetDateStart = new Date(Date.UTC(year, month - 1, day))
|
||||
const targetDateEnd = new Date(Date.UTC(year, month - 1, day + 1))
|
||||
|
||||
// Find conversation that falls on the selected date
|
||||
const conversations = store.conversations.value ?? store.conversations
|
||||
const conversation = conversations.find((conv) => {
|
||||
const convDate = new Date(conv.updated_at)
|
||||
return convDate >= targetDateStart && convDate < targetDateEnd
|
||||
})
|
||||
const conversations = store.conversations
|
||||
const conversation = conversations.find((conv: { id: string; updated_at: string }) => formatDateKey(new Date(conv.updated_at)) === dateKey)
|
||||
|
||||
if (conversation) {
|
||||
selectConversation(conversation.id)
|
||||
} else {
|
||||
// No conversation for this date, create a new one
|
||||
newConversation()
|
||||
return
|
||||
}
|
||||
|
||||
newConversation()
|
||||
}).catch((err) => {
|
||||
console.error('[Calendar] Error loading conversations:', err)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '')
|
||||
|
||||
function parseDotenvFile(filePath: string) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {}
|
||||
const text = fs.readFileSync(filePath, 'utf-8')
|
||||
const result: Record<string, string> = {}
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const eq = line.indexOf('=')
|
||||
if (eq <= 0) continue
|
||||
const key = line.slice(0, eq).trim()
|
||||
let value = line.slice(eq + 1).trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Vite only loads env files under `frontend/` by default.
|
||||
// Many Jarvis setups keep HOST/PORT in repo root `.env`, so we read it explicitly as a fallback.
|
||||
const rootEnvPath = path.resolve(__dirname, '../.env')
|
||||
const rootEnv = parseDotenvFile(rootEnvPath)
|
||||
const rootHost = (rootEnv.HOST || '127.0.0.1').trim()
|
||||
const rootPort = (rootEnv.PORT || '').trim()
|
||||
const rootApi = (rootEnv.VITE_API_URL || (rootPort ? `http://${rootHost}:${rootPort}` : '')).trim()
|
||||
|
||||
const apiTarget = env.VITE_API_URL || rootApi || 'http://localhost:8000'
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
@@ -15,7 +49,7 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: env.VITE_API_URL,
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user