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:
2026-04-08 00:13:06 +08:00
parent 52fb619084
commit e637c8ca2f
5 changed files with 168 additions and 109 deletions

View File

@@ -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',

View File

@@ -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}&current=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,
}
}

View File

@@ -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

View File

@@ -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)
})

View File

@@ -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,
},
},