📄 App.jsx
/home/palash/git/local-ai/src/App.jsx
Language: jsx • Lines: 427
import { useState, useEffect, useRef, useCallback } from 'react'
import * as api from './api'
import ModelBar from './components/ModelBar'
import Sidebar from './components/Sidebar'
import ChatArea from './components/ChatArea'
import InputBar from './components/InputBar'
import ImageLightbox from './components/ImageLightbox'
import OverloadWarning from './components/OverloadWarning'
import TaskPanel from './components/TaskPanel'
import LocationPrompt from './components/LocationPrompt'
import PublicShareView from './components/PublicShareView'

function shareTokenFromPath() {
  const m = /^\/s\/([A-Za-z0-9]+)\/?$/.exec(window.location.pathname)
  return m ? m[1] : null
}

export default function App() {
  const [authenticated, setAuthenticated] = useState(false)
  const [publicShareToken, setPublicShareToken] = useState(() => shareTokenFromPath())
  const [username, setUsername] = useState('')
  const [sessions, setSessions] = useState([])
  const [currentSessionId, setCurrentSessionId] = useState(null)
  const [messages, setMessages] = useState([])
  const [pendingMessages, setPendingMessages] = useState({})
  const [tokenEstimate, setTokenEstimate] = useState(0)
  const [contextCompressed, setContextCompressed] = useState(false)
  const [rawTokenEstimate, setRawTokenEstimate] = useState(0)
  const [maxContext, setMaxContext] = useState(24576)
  const [modelStatus, setModelStatus] = useState('unloaded')
  const [modelTps, setModelTps] = useState(null)
  const [overheated, setOverheated] = useState(false)
  const [gpuTemp, setGpuTemp] = useState(null)
  const [ramEvacuating, setRamEvacuating] = useState(false)
  const [reminderCount, setReminderCount] = useState(0)
  const [sidebarOpen, setSidebarOpen] = useState(false)
  const [lightboxSrc, setLightboxSrc] = useState(null)
  const [loadingSessions, setLoadingSessions] = useState({})
  const [showTasks, setShowTasks] = useState(false)
  const [showLocationPrompt, setShowLocationPrompt] = useState(false)
  const [locationTaskId, setLocationTaskId] = useState(null)
  const [locationError, setLocationError] = useState(null)
  const sessionRef = useRef(null)
  const selectingRef = useRef(false)
  const pendingMessagesRef = useRef({})
  const resolvedTasksRef = useRef(new Set())

  useEffect(() => {
    // Authentication is enforced by nginx's auth_request against Authentik:
    // by the time this SPA loads, the browser already has a valid SSO session
    // and /api/check-auth answers from the forwarded X-Authentik-* headers.
    api.checkAuth()
      .then(res => {
        if (res.authenticated) {
          setAuthenticated(true)
          if (res.username) setUsername(res.username)
          loadSessions().then(list => {
            const lastSid = localStorage.getItem('last_sid')
            if (lastSid && list.some(s => s.session_id === lastSid)) {
              switchSession(lastSid)
            } else if (list.length > 0) {
              switchSession(list[0].session_id)
            }
          })
        } else {
          setAuthenticated(false)
        }
      })
      .catch(() => setAuthenticated(false))
  }, [])

  useEffect(() => {
    const handler = () => {
      setAuthenticated(false);
      setUsername('');
      setSessions([]);
      sessionRef.current = null; setCurrentSessionId(null);
      setMessages([]);
      setPendingMessages({});
      setSidebarOpen(false);
    };
    window.addEventListener('auth:unauthorized', handler);
    return () => window.removeEventListener('auth:unauthorized', handler);
  }, [])

  const hasPendingForCurrent = Object.values(pendingMessages).some(
    p => p.sessionId === currentSessionId
  )

  useEffect(() => {
    pendingMessagesRef.current = pendingMessages
  }, [pendingMessages])

  // ---- Auth ----
  async function handleLogout() {
    console.log('[logout] called')
    await api.logout()
    setAuthenticated(false)
    setUsername('')
    setSessions([])
    sessionRef.current = null; setCurrentSessionId(null)
    setMessages([])
    setPendingMessages({})
    setSidebarOpen(false)
  }

  // ---- Sessions ----
  async function loadSessions() {
    const list = await api.fetchSessions()
    setSessions(list)
    return list
  }

  function loadSessionMessages(sid) {
    api.fetchMessages(sid).then(data => {
      if (sessionRef.current !== sid) return
      setMessages(data.messages || [])
      setTokenEstimate(data.token_estimate || 0)
      setContextCompressed(!!data.context_compressed)
      setRawTokenEstimate(data.raw_token_estimate || 0)
    }).catch(() => {
      if (sessionRef.current !== sid) return
      setMessages([])
      setTokenEstimate(0)
      setContextCompressed(false)
      setRawTokenEstimate(0)
    })
  }

  async function switchSession(sid) {
    sessionRef.current = sid; setCurrentSessionId(sid)
    localStorage.setItem('last_sid', sid)
    loadSessionMessages(sid)
    closeSidebar()
  }

  async function newChat() {
    const data = await api.createSession()
    sessionRef.current = data.session_id; setCurrentSessionId(data.session_id)
    localStorage.setItem('last_sid', data.session_id)
    setMessages([])
    setTokenEstimate(0)
    setContextCompressed(false)
    setRawTokenEstimate(0)
    const list = await loadSessions()
    setSessions(list)
    closeSidebar()
  }

  async function deleteSession_(sid) {
    if (!confirm('Delete this session?')) return
    await api.deleteSession(sid)
    setPendingMessages(prev => {
      const next = { ...prev }
      for (const [tid, p] of Object.entries(prev)) {
        if (p.sessionId === sid) delete next[tid]
      }
      return next
    })
    setStoredPending(getStoredPending().filter(p => p.sid !== sid))
    const list = await loadSessions()
    if (sid === currentSessionId) {
      if (list.length > 0) {
        switchSession(list[0].session_id)
      } else {
        newChat()
      }
    } else if (currentSessionId) {
      loadSessionMessages(currentSessionId)
    }
  }

  async function renameSession_(sid) {
    const s = sessions.find(x => x.session_id === sid)
    const newName = prompt('Session name:', s ? s.name : '')
    if (!newName || newName.trim() === '') return
    const prevSid = currentSessionId
    await api.renameSession(sid, newName.trim())
    const list = await loadSessions()
    sessionRef.current = prevSid; setCurrentSessionId(prevSid)
    setSessions(list)
  }

  function closeSidebar() {
    setSidebarOpen(false)
  }

  // ---- Chat / Send ----
  async function handleSend(text, image, research, cpu) {
    if (!currentSessionId) return
    const taskSid = currentSessionId
    setLoadingSessions(prev => ({ ...prev, [taskSid]: (prev[taskSid] || 0) + 1 }))

    const userMsg = { role: 'user', content: text || '\uD83D\uDCC4 file', _timestamp: new Date().toISOString() }

    try {
      const data = await api.sendMessage(currentSessionId, text || '', image || undefined, undefined, undefined, research, cpu)
      const taskId = data.task_id

      setPendingMessages(prev => ({
        ...prev,
        [taskId]: { sessionId: taskSid, status: 'working', message: 'Thinking...', taskId, reasoning: '', _userMsg: userMsg },
      }))

      const stored = getStoredPending()
      stored.push({ task_id: taskId, sid: taskSid })
      setStoredPending(stored)
    } catch (err) {
      setMessages(prev => [...prev, userMsg, { role: 'assistant', content: 'Error: ' + err.message }])
    }

    setLoadingSessions(prev => {
      const next = { ...prev }
      next[taskSid] = (next[taskSid] || 1) - 1
      if (!next[taskSid]) delete next[taskSid]
      return next
    })
  }

  // ---- Task completion callbacks (called by the active PendingMessage) ----
  const handlePendingResolved = useCallback((pending, st) => {
    const taskId = pending.taskId
    if (resolvedTasksRef.current.has(taskId)) return
    resolvedTasksRef.current.add(taskId)
    setPendingMessages(prev => {
      const next = { ...prev }
      delete next[taskId]
      return next
    })
    if (st.status === 'done') {
      const userMsg = pending._userMsg
      const assistantMsg = {
        role: 'assistant',
        content: st.response || '',
        _elapsed_ms: st._elapsed_ms != null ? st._elapsed_ms : null,
        _reasoning: st.reasoning || '',
        _image_url: st.image || st._image_url,
        _gen_prompt: st.gen_prompt,
        _tools_used: st.tools_used || [],
        _image_model: st._image_model,
        _search_details: st._search_details || [],
      }
      if (pending.sessionId === sessionRef.current) {
        setMessages(prev => [...prev, ...(userMsg ? [userMsg] : []), assistantMsg])
      }
      if (st.token_estimate != null) setTokenEstimate(st.token_estimate)
      setContextCompressed(!!st.context_compressed)
      if (st.raw_token_estimate != null) setRawTokenEstimate(st.raw_token_estimate)
      if (st.predicted_per_second != null) setModelTps(st.predicted_per_second)
      if (st.session_name != null && st.session_id) {
        setSessions(prev => prev.map(s => s.session_id === st.session_id ? { ...s, name: st.session_name } : s))
      }
    } else {
      if (pending.sessionId === sessionRef.current) {
        setMessages(prev => [...prev, ...(pending._userMsg ? [pending._userMsg] : []), {
          role: 'assistant',
          content: 'Error: ' + (st.error || 'Task was lost — please retry.'),
        }])
      }
    }
    const remaining = getStoredPending().filter(p => p.task_id !== taskId)
    setStoredPending(remaining)
  }, [])

  const handleLocationNeeded = useCallback((taskId) => {
    setShowLocationPrompt(true)
    setLocationTaskId(taskId)
    setLocationError(null)
  }, [])

  const closeLightbox = useCallback(() => setLightboxSrc(null), [])

  // ---- Background task resolution ----
  // Foreground pending tasks are self-polled by their PendingMessage (so only that
  // message re-renders). This loop resolves tasks whose session is not currently open.
  useEffect(() => {
    if (!authenticated) return
    const interval = setInterval(async () => {
      for (const [taskId, p] of Object.entries(pendingMessagesRef.current)) {
        if (p.sessionId === sessionRef.current) continue
        try {
          const st = await api.getTaskStatus(taskId)
          const terminal = st && (st.status === 'done' || st.status === 'error' || st.status === 'cancelled' || st.status === 'unknown' || st.status === 'not_found')
          if (terminal) {
            handlePendingResolved(p, st)
          } else if (st.message === 'location_needed') {
            handleLocationNeeded(taskId)
          }
        } catch { }
      }
    }, 2000)
    return () => clearInterval(interval)
  }, [authenticated, handlePendingResolved, handleLocationNeeded])

  // ---- Model status polling ----
  useEffect(() => {
    if (!authenticated) return
    const interval = setInterval(async () => {
      try {
        const data = await api.getModelStatus()
        setModelStatus(data.model)
        setOverheated(data.overheated)
        setGpuTemp(data.gpu_temp)
        if (data.ram_evacuating != null) setRamEvacuating(data.ram_evacuating)
        if (data.predicted_per_second != null) setModelTps(data.predicted_per_second)
        if (data.max_context != null) setMaxContext(data.max_context)
        if (data.reminder_count != null) setReminderCount(data.reminder_count)
      } catch { /* ignore */ }
    }, 2000)
    return () => clearInterval(interval)
  }, [authenticated])

  // ---- Resume pending tasks on page load ----
  useEffect(() => {
    if (!authenticated) return
    const stored = getStoredPending()
    if (stored.length > 0) {
      const pMap = {}
      stored.forEach(item => {
        pMap[item.task_id] = { sessionId: item.sid, status: 'working', message: 'Thinking...', taskId: item.task_id, reasoning: '' }
      })
      setPendingMessages(pMap)
    }
  }, [authenticated])

  function handleLocationAllow() {
    const tid = locationTaskId
    navigator.geolocation.getCurrentPosition(
      pos => {
        setShowLocationPrompt(false)
        setLocationTaskId(null)
        api.sendLocation(pos.coords.latitude, pos.coords.longitude, tid)
      },
      err => {
        if (err.code === 1) {
          setLocationError('Location access is blocked in your browser. Please enable it in browser settings and try again.')
        } else {
          setLocationError('Could not get location: ' + err.message + '. Try again or click Deny.')
        }
      },
      { timeout: 10000, enableHighAccuracy: false }
    )
  }

  function handleLocationDeny() {
    setShowLocationPrompt(false)
    const tid = locationTaskId
    setLocationTaskId(null)
    setLocationError(null)
    api.denyLocation(tid)
  }

  if (publicShareToken) {
    return (
      <PublicShareView
        token={publicShareToken}
        onExit={() => {
          window.history.replaceState({}, '', '/')
          setPublicShareToken(null)
        }}
      />
    )
  }

  return (
    <>
      {showLocationPrompt && <LocationPrompt onAllow={handleLocationAllow} onDeny={handleLocationDeny} error={locationError} />}
      <div style={{ display: authenticated ? '' : 'none' }}>
        <ModelBar
          modelStatus={modelStatus}
          modelTps={modelTps}
          tokenEstimate={tokenEstimate}
          contextCompressed={contextCompressed}
          rawTokenEstimate={rawTokenEstimate}
          maxContext={maxContext}
          onToggleSidebar={() => setSidebarOpen(o => !o)}
          username={username}
          onLogout={handleLogout}
          reminderCount={reminderCount}
          onToggleTasks={() => setShowTasks(o => !o)}
        />
        <div id="app-container">
          <Sidebar
            sessions={sessions}
            currentSessionId={currentSessionId}
            onSwitchSession={switchSession}
            onNewChat={newChat}
            onRenameSession={renameSession_}
            onDeleteSession={deleteSession_}
            onClose={closeSidebar}
            open={sidebarOpen}
          />
          <OverloadWarning overheated={overheated} gpuTemp={gpuTemp} ramEvacuating={ramEvacuating} />
          <ChatArea
            messages={messages}
            pendingMessages={pendingMessages}
            currentSessionId={currentSessionId}
            onImageOpen={setLightboxSrc}
            selectingRef={selectingRef}
            onPendingResolved={handlePendingResolved}
            onLocationNeeded={handleLocationNeeded}
          />
          <InputBar
            onSend={handleSend}
            hasPending={hasPendingForCurrent}
          />
        </div>
        <ImageLightbox src={lightboxSrc} onClose={closeLightbox} />
        {showTasks && <TaskPanel onClose={() => setShowTasks(false)} />}
      </div>
    </>
  )
}

function getStoredPending() {
  try {
    return JSON.parse(sessionStorage.getItem('opencode_pending')) || []
  } catch {
    return []
  }
}

function setStoredPending(arr) {
  try {
    sessionStorage.setItem('opencode_pending', JSON.stringify(arr))
  } catch { /* ignore */ }
}