app/ui: refactor to use Ollama endpoints for user auth and health checks (#13081)

This commit is contained in:
Eva H 2025-12-10 15:24:31 -05:00 committed by GitHub
parent bbbb6b2a01
commit 7cf6f18c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 106 additions and 250 deletions

View File

@ -273,10 +273,6 @@ func main() {
Handler: uiServer.Handler(), Handler: uiServer.Handler(),
} }
if _, err := uiServer.UserData(ctx); err != nil {
slog.Warn("failed to load user data", "error", err)
}
// Start the UI server // Start the UI server
slog.Info("starting ui server", "port", port) slog.Info("starting ui server", "port", port)
go func() { go func() {
@ -320,6 +316,17 @@ func main() {
slog.Debug("no URL scheme request to handle") 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) osRun(cancel, hasCompletedFirstRun, startHidden)
slog.Info("shutting down desktop server") slog.Info("shutting down desktop server")
@ -361,7 +368,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
return false 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 { if err != nil {
slog.Debug("failed to call local auth endpoint", "error", err) slog.Debug("failed to call local auth endpoint", "error", err)
return false return false

View File

@ -469,26 +469,24 @@ export class HealthResponse {
} }
export class User { export class User {
id: string; id: string;
name: string;
email: string; email: string;
avatarURL: string; name: string;
plan: string; bio?: string;
bio: string; avatarurl?: string;
firstName: string; firstname?: string;
lastName: string; lastname?: string;
overThreshold: boolean; plan?: string;
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"]; this.id = source["id"];
this.name = source["name"];
this.email = source["email"]; this.email = source["email"];
this.avatarURL = source["avatarURL"]; this.name = source["name"];
this.plan = source["plan"];
this.bio = source["bio"]; this.bio = source["bio"];
this.firstName = source["firstName"]; this.avatarurl = source["avatarurl"];
this.lastName = source["lastName"]; this.firstname = source["firstname"];
this.overThreshold = source["overThreshold"]; this.lastname = source["lastname"];
this.plan = source["plan"];
} }
} }
export class Attachment { export class Attachment {

View File

@ -15,7 +15,7 @@ import {
import { parseJsonlFromResponse } from "./util/jsonl-parsing"; import { parseJsonlFromResponse } from "./util/jsonl-parsing";
import { ollamaClient as ollama } from "./lib/ollama-client"; import { ollamaClient as ollama } from "./lib/ollama-client";
import type { ModelResponse } from "ollama/browser"; 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 // Extend Model class with utility methods
declare module "@/gotypes" { declare module "@/gotypes" {
@ -27,7 +27,6 @@ declare module "@/gotypes" {
Model.prototype.isCloud = function (): boolean { Model.prototype.isCloud = function (): boolean {
return this.model.endsWith("cloud"); return this.model.endsWith("cloud");
}; };
// Helper function to convert Uint8Array to base64 // Helper function to convert Uint8Array to base64
function uint8ArrayToBase64(uint8Array: Uint8Array): string { function uint8ArrayToBase64(uint8Array: Uint8Array): string {
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
@ -42,44 +41,50 @@ function uint8ArrayToBase64(uint8Array: Uint8Array): string {
} }
export async function fetchUser(): Promise<User | null> { export async function fetchUser(): Promise<User | null> {
try { const response = await fetch(`${API_BASE}/api/me`, {
const response = await fetch(`${API_BASE}/api/v1/me`, { method: "POST",
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<string> {
const response = await fetch(`${API_BASE}/api/v1/connect`, {
method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
if (!response.ok) { if (response.ok) {
throw new Error("Failed to fetch connect URL"); 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(); if (response.status === 401 || response.status === 403) {
return data.connect_url; return null;
}
throw new Error(`Failed to fetch user: ${response.status}`);
}
export async function fetchConnectUrl(): Promise<string> {
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<void> { export async function disconnectUser(): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/disconnect`, { const response = await fetch(`${API_BASE}/api/signout`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -389,7 +394,8 @@ export async function getInferenceCompute(): Promise<InferenceCompute[]> {
export async function fetchHealth(): Promise<boolean> { export async function fetchHealth(): Promise<boolean> {
try { 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", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -398,7 +404,8 @@ export async function fetchHealth(): Promise<boolean> {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
return data.healthy || false; // If we get a version back, the server is healthy
return !!data.version;
} }
return false; return false;

View File

@ -299,9 +299,9 @@ export default function Settings() {
</Button> </Button>
</div> </div>
</div> </div>
{user?.avatarURL && ( {user?.avatarurl && (
<img <img
src={user.avatarURL} src={user.avatarurl}
alt={user?.name} alt={user?.name}
className="h-10 w-10 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0" className="h-10 w-10 rounded-full bg-neutral-200 dark:bg-neutral-700 flex-shrink-0"
onError={(e) => { onError={(e) => {

View File

@ -1,29 +1,20 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { fetchUser, fetchConnectUrl, disconnectUser } from "@/api"; import { fetchUser, fetchConnectUrl, disconnectUser } from "@/api";
export function useUser() { export function useUser() {
const queryClient = useQueryClient(); 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({ const userQuery = useQuery({
queryKey: ["user"], queryKey: ["user"],
queryFn: () => fetchUser(), queryFn: async () => {
const result = await fetchUser();
return result;
},
staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache for 10 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 // 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 { return {
user: userQuery.data, user: userQuery.data,
isLoading: isLoading,
!initialDataLoaded ||
(userQuery.isLoading && userQuery.data === undefined), // Show loading until initial data is loaded
isError: userQuery.isError, isError: userQuery.isError,
error: userQuery.error, error: userQuery.error,
isAuthenticated: Boolean(userQuery.data?.name), isAuthenticated,
refreshUser: refreshUser.mutate, refreshUser: refreshUser.mutate,
isRefreshing: refreshUser.isPending, isRefreshing: refreshUser.isPending,
refetchUser: userQuery.refetch, refetchUser: userQuery.refetch,

View File

@ -8,3 +8,6 @@ export const API_BASE = import.meta.env.DEV ? DEV_API_URL : "";
export const OLLAMA_HOST = import.meta.env.DEV export const OLLAMA_HOST = import.meta.env.DEV
? DEV_API_URL ? DEV_API_URL
: window.location.origin; : window.location.origin;
export const OLLAMA_DOT_COM =
import.meta.env.VITE_OLLAMA_DOT_COM_URL || "https://ollama.com";

View File

@ -5,13 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { fetchUser } from "./api"; import { fetchUser } from "./api";
import { StreamingProvider } from "./contexts/StreamingContext"; import { StreamingProvider } from "./contexts/StreamingContext";
import { User } from "@/gotypes";
declare global {
interface Window {
__initialUserDataPromise?: Promise<User | null>;
}
}
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -24,27 +17,11 @@ const queryClient = new QueryClient({
}, },
}); });
// Track initial user data fetch fetchUser().then((userData) => {
let initialUserDataPromise: Promise<User | null> | null = null; if (userData) {
// Initialize user data on app startup
const initializeUserData = async () => {
try {
const userData = await fetchUser();
queryClient.setQueryData(["user"], 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({ const router = createRouter({
routeTree, routeTree,

View File

@ -101,15 +101,14 @@ type HealthResponse struct {
} }
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Email string `json:"email"`
Email string `json:"email"` Name string `json:"name"`
AvatarURL string `json:"avatarURL"` Bio string `json:"bio,omitempty"`
Plan string `json:"plan"` AvatarURL string `json:"avatarurl,omitempty"`
Bio string `json:"bio"` FirstName string `json:"firstname,omitempty"`
FirstName string `json:"firstName"` LastName string `json:"lastname,omitempty"`
LastName string `json:"lastName"` Plan string `json:"plan,omitempty"`
OverThreshold bool `json:"overThreshold"`
} }
type Attachment struct { type Attachment struct {

View File

@ -23,7 +23,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/ollama/ollama/api" "github.com/ollama/ollama/api"
"github.com/ollama/ollama/app/auth"
"github.com/ollama/ollama/app/server" "github.com/ollama/ollama/app/server"
"github.com/ollama/ollama/app/store" "github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/tools" "github.com/ollama/ollama/app/tools"
@ -264,11 +263,10 @@ func (s *Server) Handler() http.Handler {
ollamaProxy := s.ollamaProxy() ollamaProxy := s.ollamaProxy()
mux.Handle("GET /api/tags", ollamaProxy) mux.Handle("GET /api/tags", ollamaProxy)
mux.Handle("POST /api/show", ollamaProxy) mux.Handle("POST /api/show", ollamaProxy)
mux.Handle("GET /api/version", ollamaProxy)
mux.Handle("GET /api/v1/me", handle(s.me)) mux.Handle("HEAD /api/version", ollamaProxy)
mux.Handle("POST /api/v1/disconnect", handle(s.disconnect)) mux.Handle("POST /api/me", ollamaProxy)
mux.Handle("GET /api/v1/connect", handle(s.connectURL)) mux.Handle("POST /api/signout", ollamaProxy)
mux.Handle("GET /api/v1/health", handle(s.health))
// React app - catch all non-API routes and serve the React app // React app - catch all non-API routes and serve the React app
mux.Handle("GET /", s.appHandler()) 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 // 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") resp, err := s.doSelfSigned(ctx, http.MethodPost, "/api/me")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to call ollama.com/api/me: %w", err) 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) 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 { if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("failed to parse user response: %w", err) 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 return &user, nil
} }
func waitForServer(ctx context.Context) error { // WaitForServer waits for the Ollama server to be ready
timeout := time.Now().Add(10 * time.Second) func WaitForServer(ctx context.Context, timeout time.Duration) error {
// TODO: this avoids an error on first load of the app deadline := time.Now().Add(timeout)
// however we should either show a loading state or for time.Now().Before(deadline) {
// wait for the Ollama server to be ready before redirecting
for {
c, err := api.ClientFromEnvironment() c, err := api.ClientFromEnvironment()
if err != nil { if err != nil {
return err return err
} }
if _, err := c.Version(ctx); err == nil { if _, err := c.Version(ctx); err == nil {
break slog.Debug("ollama server is ready")
} return nil
if time.Now().After(timeout) {
return fmt.Errorf("timeout waiting for Ollama server to be ready")
} }
time.Sleep(10 * time.Millisecond) 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 { 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() id, err := uuid.NewV7()
if err != nil { 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 { func (s *Server) getInferenceCompute(w http.ResponseWriter, r *http.Request) error {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel() defer cancel()