diff --git a/app/cmd/app/app.go b/app/cmd/app/app.go index d09e04b54..7e183b8df 100644 --- a/app/cmd/app/app.go +++ b/app/cmd/app/app.go @@ -273,10 +273,6 @@ func main() { Handler: uiServer.Handler(), } - if _, err := uiServer.UserData(ctx); err != nil { - slog.Warn("failed to load user data", "error", err) - } - // Start the UI server slog.Info("starting ui server", "port", port) go func() { @@ -320,6 +316,17 @@ func main() { slog.Debug("no URL scheme request to handle") } + go func() { + slog.Debug("waiting for ollama server to be ready") + if err := ui.WaitForServer(ctx, 10*time.Second); err != nil { + slog.Warn("ollama server not ready, continuing anyway", "error", err) + } + + if _, err := uiServer.UserData(ctx); err != nil { + slog.Warn("failed to load user data", "error", err) + } + }() + osRun(cancel, hasCompletedFirstRun, startHidden) slog.Info("shutting down desktop server") @@ -361,7 +368,7 @@ func checkUserLoggedIn(uiServerPort int) bool { return false } - resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/v1/me", uiServerPort)) + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/me", uiServerPort), "application/json", nil) if err != nil { slog.Debug("failed to call local auth endpoint", "error", err) return false diff --git a/app/ui/app/codegen/gotypes.gen.ts b/app/ui/app/codegen/gotypes.gen.ts index a077c8546..0bf86f2b4 100644 --- a/app/ui/app/codegen/gotypes.gen.ts +++ b/app/ui/app/codegen/gotypes.gen.ts @@ -469,26 +469,24 @@ export class HealthResponse { } export class User { id: string; - name: string; email: string; - avatarURL: string; - plan: string; - bio: string; - firstName: string; - lastName: string; - overThreshold: boolean; + name: string; + bio?: string; + avatarurl?: string; + firstname?: string; + lastname?: string; + plan?: string; constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.id = source["id"]; - this.name = source["name"]; this.email = source["email"]; - this.avatarURL = source["avatarURL"]; - this.plan = source["plan"]; + this.name = source["name"]; this.bio = source["bio"]; - this.firstName = source["firstName"]; - this.lastName = source["lastName"]; - this.overThreshold = source["overThreshold"]; + this.avatarurl = source["avatarurl"]; + this.firstname = source["firstname"]; + this.lastname = source["lastname"]; + this.plan = source["plan"]; } } export class Attachment { diff --git a/app/ui/app/src/api.ts b/app/ui/app/src/api.ts index a701a30a5..273850d6b 100644 --- a/app/ui/app/src/api.ts +++ b/app/ui/app/src/api.ts @@ -15,7 +15,7 @@ import { import { parseJsonlFromResponse } from "./util/jsonl-parsing"; import { ollamaClient as ollama } from "./lib/ollama-client"; import type { ModelResponse } from "ollama/browser"; -import { API_BASE } from "./lib/config"; +import { API_BASE, OLLAMA_DOT_COM } from "./lib/config"; // Extend Model class with utility methods declare module "@/gotypes" { @@ -27,7 +27,6 @@ declare module "@/gotypes" { Model.prototype.isCloud = function (): boolean { return this.model.endsWith("cloud"); }; - // Helper function to convert Uint8Array to base64 function uint8ArrayToBase64(uint8Array: Uint8Array): string { const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow @@ -42,44 +41,50 @@ function uint8ArrayToBase64(uint8Array: Uint8Array): string { } export async function fetchUser(): Promise { - try { - const response = await fetch(`${API_BASE}/api/v1/me`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - const userData: User = await response.json(); - return userData; - } - - return null; - } catch (error) { - console.error("Error fetching user:", error); - return null; - } -} - -export async function fetchConnectUrl(): Promise { - const response = await fetch(`${API_BASE}/api/v1/connect`, { - method: "GET", + const response = await fetch(`${API_BASE}/api/me`, { + method: "POST", headers: { "Content-Type": "application/json", }, }); - if (!response.ok) { - throw new Error("Failed to fetch connect URL"); + if (response.ok) { + const userData: User = await response.json(); + + if (userData.avatarurl && !userData.avatarurl.startsWith("http")) { + userData.avatarurl = `${OLLAMA_DOT_COM}${userData.avatarurl}`; + } + + return userData; } - const data = await response.json(); - return data.connect_url; + if (response.status === 401 || response.status === 403) { + return null; + } + + throw new Error(`Failed to fetch user: ${response.status}`); +} + +export async function fetchConnectUrl(): Promise { + const response = await fetch(`${API_BASE}/api/me`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.status === 401) { + const data = await response.json(); + if (data.signin_url) { + return data.signin_url; + } + } + + throw new Error("Failed to fetch connect URL"); } export async function disconnectUser(): Promise { - const response = await fetch(`${API_BASE}/api/v1/disconnect`, { + const response = await fetch(`${API_BASE}/api/signout`, { method: "POST", headers: { "Content-Type": "application/json", @@ -389,7 +394,8 @@ export async function getInferenceCompute(): Promise { export async function fetchHealth(): Promise { try { - const response = await fetch(`${API_BASE}/api/v1/health`, { + // Use the /api/version endpoint as a health check + const response = await fetch(`${API_BASE}/api/version`, { method: "GET", headers: { "Content-Type": "application/json", @@ -398,7 +404,8 @@ export async function fetchHealth(): Promise { if (response.ok) { const data = await response.json(); - return data.healthy || false; + // If we get a version back, the server is healthy + return !!data.version; } return false; diff --git a/app/ui/app/src/components/Settings.tsx b/app/ui/app/src/components/Settings.tsx index c56a97b35..057f7477d 100644 --- a/app/ui/app/src/components/Settings.tsx +++ b/app/ui/app/src/components/Settings.tsx @@ -299,9 +299,9 @@ export default function Settings() { - {user?.avatarURL && ( + {user?.avatarurl && ( {user?.name} { diff --git a/app/ui/app/src/hooks/useUser.ts b/app/ui/app/src/hooks/useUser.ts index 5f7a4dade..b4e6698eb 100644 --- a/app/ui/app/src/hooks/useUser.ts +++ b/app/ui/app/src/hooks/useUser.ts @@ -1,29 +1,20 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useState } from "react"; import { fetchUser, fetchConnectUrl, disconnectUser } from "@/api"; export function useUser() { const queryClient = useQueryClient(); - const [initialDataLoaded, setInitialDataLoaded] = useState(false); - - // Wait for initial data to be loaded - useEffect(() => { - const initialPromise = window.__initialUserDataPromise; - if (initialPromise) { - initialPromise.finally(() => { - setInitialDataLoaded(true); - }); - } else { - setInitialDataLoaded(true); - } - }, []); const userQuery = useQuery({ queryKey: ["user"], - queryFn: () => fetchUser(), + queryFn: async () => { + const result = await fetchUser(); + return result; + }, staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes - initialData: null, // Start with null to prevent flashing + retry: 10, + retryDelay: (attemptIndex) => Math.min(500 * attemptIndex, 2000), + refetchOnMount: true, // Always fetch when component mounts }); // Mutation to refresh user data @@ -49,14 +40,15 @@ export function useUser() { }, }); + const isLoading = userQuery.isLoading || userQuery.isFetching; + const isAuthenticated = Boolean(userQuery.data?.name); + return { user: userQuery.data, - isLoading: - !initialDataLoaded || - (userQuery.isLoading && userQuery.data === undefined), // Show loading until initial data is loaded + isLoading, isError: userQuery.isError, error: userQuery.error, - isAuthenticated: Boolean(userQuery.data?.name), + isAuthenticated, refreshUser: refreshUser.mutate, isRefreshing: refreshUser.isPending, refetchUser: userQuery.refetch, diff --git a/app/ui/app/src/lib/config.ts b/app/ui/app/src/lib/config.ts index c11243967..7c5385d74 100644 --- a/app/ui/app/src/lib/config.ts +++ b/app/ui/app/src/lib/config.ts @@ -8,3 +8,6 @@ export const API_BASE = import.meta.env.DEV ? DEV_API_URL : ""; export const OLLAMA_HOST = import.meta.env.DEV ? DEV_API_URL : window.location.origin; + +export const OLLAMA_DOT_COM = + import.meta.env.VITE_OLLAMA_DOT_COM_URL || "https://ollama.com"; diff --git a/app/ui/app/src/main.tsx b/app/ui/app/src/main.tsx index 1ffe37ef6..3e325a3ca 100644 --- a/app/ui/app/src/main.tsx +++ b/app/ui/app/src/main.tsx @@ -5,13 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { routeTree } from "./routeTree.gen"; import { fetchUser } from "./api"; import { StreamingProvider } from "./contexts/StreamingContext"; -import { User } from "@/gotypes"; - -declare global { - interface Window { - __initialUserDataPromise?: Promise; - } -} const queryClient = new QueryClient({ defaultOptions: { @@ -24,27 +17,11 @@ const queryClient = new QueryClient({ }, }); -// Track initial user data fetch -let initialUserDataPromise: Promise | null = null; - -// Initialize user data on app startup -const initializeUserData = async () => { - try { - const userData = await fetchUser(); +fetchUser().then((userData) => { + if (userData) { queryClient.setQueryData(["user"], userData); - return userData; - } catch (error) { - console.error("Error initializing user data:", error); - queryClient.setQueryData(["user"], null); - return null; } -}; - -// Start initialization immediately and track the promise -initialUserDataPromise = initializeUserData(); - -// Export the promise so hooks can await it -window.__initialUserDataPromise = initialUserDataPromise; +}); const router = createRouter({ routeTree, diff --git a/app/ui/responses/types.go b/app/ui/responses/types.go index 438dd55e4..2da6623fd 100644 --- a/app/ui/responses/types.go +++ b/app/ui/responses/types.go @@ -101,15 +101,14 @@ type HealthResponse struct { } type User struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - AvatarURL string `json:"avatarURL"` - Plan string `json:"plan"` - Bio string `json:"bio"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - OverThreshold bool `json:"overThreshold"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` + AvatarURL string `json:"avatarurl,omitempty"` + FirstName string `json:"firstname,omitempty"` + LastName string `json:"lastname,omitempty"` + Plan string `json:"plan,omitempty"` } type Attachment struct { diff --git a/app/ui/ui.go b/app/ui/ui.go index 1d0e25796..5a64705de 100644 --- a/app/ui/ui.go +++ b/app/ui/ui.go @@ -23,7 +23,6 @@ import ( "github.com/google/uuid" "github.com/ollama/ollama/api" - "github.com/ollama/ollama/app/auth" "github.com/ollama/ollama/app/server" "github.com/ollama/ollama/app/store" "github.com/ollama/ollama/app/tools" @@ -264,11 +263,10 @@ func (s *Server) Handler() http.Handler { ollamaProxy := s.ollamaProxy() mux.Handle("GET /api/tags", ollamaProxy) mux.Handle("POST /api/show", ollamaProxy) - - mux.Handle("GET /api/v1/me", handle(s.me)) - mux.Handle("POST /api/v1/disconnect", handle(s.disconnect)) - mux.Handle("GET /api/v1/connect", handle(s.connectURL)) - mux.Handle("GET /api/v1/health", handle(s.health)) + mux.Handle("GET /api/version", ollamaProxy) + mux.Handle("HEAD /api/version", ollamaProxy) + mux.Handle("POST /api/me", ollamaProxy) + mux.Handle("POST /api/signout", ollamaProxy) // React app - catch all non-API routes and serve the React app mux.Handle("GET /", s.appHandler()) @@ -338,7 +336,7 @@ func (s *Server) doSelfSigned(ctx context.Context, method, path string) (*http.R } // UserData fetches user data from ollama.com API for the current ollama key -func (s *Server) UserData(ctx context.Context) (*responses.User, error) { +func (s *Server) UserData(ctx context.Context) (*api.UserResponse, error) { resp, err := s.doSelfSigned(ctx, http.MethodPost, "/api/me") if err != nil { return nil, fmt.Errorf("failed to call ollama.com/api/me: %w", err) @@ -349,7 +347,7 @@ func (s *Server) UserData(ctx context.Context) (*responses.User, error) { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - var user responses.User + var user api.UserResponse if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, fmt.Errorf("failed to parse user response: %w", err) } @@ -368,29 +366,27 @@ func (s *Server) UserData(ctx context.Context) (*responses.User, error) { return &user, nil } -func waitForServer(ctx context.Context) error { - timeout := time.Now().Add(10 * time.Second) - // TODO: this avoids an error on first load of the app - // however we should either show a loading state or - // wait for the Ollama server to be ready before redirecting - for { +// WaitForServer waits for the Ollama server to be ready +func WaitForServer(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { c, err := api.ClientFromEnvironment() if err != nil { return err } if _, err := c.Version(ctx); err == nil { - break - } - if time.Now().After(timeout) { - return fmt.Errorf("timeout waiting for Ollama server to be ready") + slog.Debug("ollama server is ready") + return nil } time.Sleep(10 * time.Millisecond) } - return nil + return errors.New("timeout waiting for Ollama server to be ready") } func (s *Server) createChat(w http.ResponseWriter, r *http.Request) error { - waitForServer(r.Context()) + if err := WaitForServer(r.Context(), 10*time.Second); err != nil { + return err + } id, err := uuid.NewV7() if err != nil { @@ -1438,129 +1434,6 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error { }) } -func (s *Server) me(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodGet { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return nil - } - - user, err := s.UserData(r.Context()) - if err != nil { - // If fetching from API fails, try to return cached user data if available - if cachedUser, cacheErr := s.Store.User(); cacheErr == nil && cachedUser != nil { - s.log().Info("API request failed, returning cached user data", "error", err) - responseUser := &responses.User{ - Name: cachedUser.Name, - Email: cachedUser.Email, - Plan: cachedUser.Plan, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - return json.NewEncoder(w).Encode(responseUser) - } - - s.log().Error("failed to get user data", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return json.NewEncoder(w).Encode(responses.Error{ - Error: "failed to get user data", - }) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - return json.NewEncoder(w).Encode(user) -} - -func (s *Server) disconnect(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return nil - } - - if err := s.Store.ClearUser(); err != nil { - s.log().Warn("failed to clear cached user data", "error", err) - } - - // Get the SSH public key to encode for the delete request - pubKey, err := ollamaAuth.GetPublicKey() - if err != nil { - s.log().Error("failed to get public key", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return json.NewEncoder(w).Encode(responses.Error{ - Error: "failed to get public key", - }) - } - - // Encode the key using base64 URL encoding - encodedKey := base64.RawURLEncoding.EncodeToString([]byte(pubKey)) - - // Call the /api/user/keys/{encodedKey} endpoint with DELETE - resp, err := s.doSelfSigned(r.Context(), http.MethodDelete, fmt.Sprintf("/api/user/keys/%s", encodedKey)) - if err != nil { - s.log().Error("failed to call ollama.com/api/user/keys", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return json.NewEncoder(w).Encode(responses.Error{ - Error: "failed to disconnect from ollama.com", - }) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - s.log().Error("disconnect request failed", "status", resp.StatusCode) - w.WriteHeader(http.StatusInternalServerError) - return json.NewEncoder(w).Encode(responses.Error{ - Error: "failed to disconnect from ollama.com", - }) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - return json.NewEncoder(w).Encode(map[string]string{"status": "disconnected"}) -} - -func (s *Server) connectURL(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodGet { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return nil - } - - connectURL, err := auth.BuildConnectURL(OllamaDotCom) - if err != nil { - s.log().Error("failed to build connect URL", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return json.NewEncoder(w).Encode(responses.Error{ - Error: "failed to build connect URL", - }) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - return json.NewEncoder(w).Encode(map[string]string{ - "connect_url": connectURL, - }) -} - -func (s *Server) health(w http.ResponseWriter, r *http.Request) error { - if r.Method != http.MethodGet { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return nil - } - - healthy := false - c, err := api.ClientFromEnvironment() - if err == nil { - if _, err := c.Version(r.Context()); err == nil { - healthy = true - } - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - return json.NewEncoder(w).Encode(responses.HealthResponse{ - Healthy: healthy, - }) -} - func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error { ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel()